Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fbbd93a18 | ||
|
|
95c375cfcb | ||
|
|
f8b24b354a | ||
|
|
004a1e21ab | ||
|
|
4a4f935c3f | ||
|
|
b139cc79b8 | ||
|
|
200d230d5f | ||
|
|
628a5375c3 | ||
|
|
054af7bde8 | ||
|
|
d652f61093 | ||
|
|
488074151c | ||
|
|
786d223463 | ||
|
|
3c47120f2a | ||
|
|
a10432d201 | ||
|
|
a81bc844fb | ||
|
|
a484783d6f | ||
|
|
bb479aaf98 | ||
|
|
5400096c8c | ||
|
|
c441acf364 | ||
|
|
1df51b0334 | ||
|
|
d37dd2dc27 | ||
|
|
d64f307b5d | ||
|
|
cf0ccfac36 | ||
|
|
cefbd0e3d1 | ||
|
|
8210c2b756 | ||
|
|
13b29d620f | ||
|
|
8adeae769c | ||
|
|
4f1521eea9 | ||
|
|
859acec6e9 | ||
|
|
6905cd1eb5 | ||
|
|
2ffb913c36 | ||
|
|
f18059b23f | ||
|
|
eb12eec35a | ||
|
|
4eceedc7d9 | ||
|
|
6f7ed9d09c | ||
|
|
5a619e8a20 | ||
|
|
1375d8c290 | ||
|
|
a08938ea1a | ||
|
|
2ebcf89f04 | ||
|
|
40e60f4947 | ||
|
|
0f680bd39e | ||
|
|
f1ba36da5b | ||
|
|
c541a6bd2f | ||
|
|
488941fd72 | ||
|
|
31c2602f4b | ||
|
|
c31e6e71a4 | ||
|
|
755bb60185 | ||
|
|
29180653ea | ||
|
|
f983f4566f | ||
|
|
848d3d1ddd | ||
|
|
75b8532145 | ||
|
|
09f99fd1ae | ||
|
|
6a5495e2db | ||
|
|
b13fb397ad | ||
|
|
e5f34ae0f0 | ||
|
|
4135496d2e | ||
|
|
197728d6c9 | ||
|
|
9bdc1a6593 | ||
|
|
ccd1ec2a50 | ||
|
|
8f19ba843f | ||
|
|
d5949b1e33 | ||
|
|
0a3cb8a33b | ||
|
|
15c4204006 | ||
|
|
321a086084 | ||
|
|
68634e5f63 | ||
|
|
ba2f051bd8 | ||
|
|
af504d6694 | ||
|
|
d12a526012 | ||
|
|
db681abada | ||
|
|
6a4507e596 | ||
|
|
78b5ba0b71 | ||
|
|
3fbc132625 | ||
|
|
820767e4af | ||
|
|
0de395b209 | ||
|
|
533187e875 | ||
|
|
3c5aa96fc3 | ||
|
|
07ee2bf5c5 | ||
|
|
d22b59ae5c | ||
|
|
f2819d513c | ||
|
|
e27e60c136 | ||
|
|
30d0351ae6 | ||
|
|
f2bb66720c | ||
|
|
d05debc12a | ||
|
|
3159043841 | ||
|
|
b6005059da | ||
|
|
ea9dc220f8 | ||
|
|
0478fae9c2 | ||
|
|
385c89679d | ||
|
|
2277d401fb | ||
|
|
07aa13da34 | ||
|
|
0c5c606295 | ||
|
|
a4a2ecac48 | ||
|
|
90ff6d58de | ||
|
|
f20213592c | ||
|
|
8505f4b7be | ||
|
|
34a36ff1b6 | ||
|
|
4aebe69665 | ||
|
|
7129700b2d | ||
|
|
be2b77fe7a | ||
|
|
4deffb9316 | ||
|
|
8edeb4888d | ||
|
|
1fd8edc7e0 | ||
|
|
bdf82e55e4 | ||
|
|
f9bf0a9c34 | ||
|
|
540488de55 | ||
|
|
a70cfc9180 | ||
|
|
c845078570 | ||
|
|
e165f93db5 | ||
|
|
e7c090d3fd | ||
|
|
50113cb6f5 | ||
|
|
6bcc872851 | ||
|
|
f0319e311e | ||
|
|
198cd3120f | ||
|
|
3640f737a9 | ||
|
|
9abd6a453d | ||
|
|
912092710e | ||
|
|
b42793eb58 | ||
|
|
3d4d2718ff | ||
|
|
5e2a40b17a | ||
|
|
46dc94af70 | ||
|
|
94a9412ac8 | ||
|
|
cd3aaef117 | ||
|
|
f45f5a9cd9 | ||
|
|
017c5120b9 | ||
|
|
f76e5d659f | ||
|
|
109e3846d0 | ||
|
|
87d4519ce0 | ||
|
|
d0c5bd644b | ||
|
|
79a9c510bd | ||
|
|
b656e5944d | ||
|
|
7bd6d10145 | ||
|
|
1230961c54 | ||
|
|
62dc2d744c | ||
|
|
fe28b2844f | ||
|
|
38cd3003b4 | ||
|
|
abd65d1662 | ||
|
|
91b2ddea86 | ||
|
|
8e118bcd60 | ||
|
|
913923a0da | ||
|
|
cfa7da2069 | ||
|
|
54ac88a90b | ||
|
|
9417281064 | ||
|
|
af139d65cd | ||
|
|
bfbd62b6da | ||
|
|
5771cf1ef7 | ||
|
|
8613c3b66e | ||
|
|
5e09a86104 | ||
|
|
2e6cfe5d3b | ||
|
|
b5f7f3c00c | ||
|
|
4209c5c2cf | ||
|
|
2ab4c90288 | ||
|
|
1fafe2548c | ||
|
|
abe956affc | ||
|
|
9da48d5e7f | ||
|
|
ae5dab661e | ||
|
|
5702ef05c8 | ||
|
|
6455f55d6d | ||
|
|
2706e4148f | ||
|
|
5a3a3cd0e5 | ||
|
|
f206070fd7 | ||
|
|
3fc19dbfc2 | ||
|
|
3518f346e4 | ||
|
|
38da942127 | ||
|
|
0023e33349 | ||
|
|
788c6a95a6 | ||
|
|
83fdbe912d | ||
|
|
3bd5ed2636 | ||
|
|
bd0753dbe5 | ||
|
|
90a73b8ceb | ||
|
|
2ac3d1d3bf | ||
|
|
25c523b72c | ||
|
|
9a9062f05c | ||
|
|
521e792a59 | ||
|
|
57972659dd | ||
|
|
2af795f5a4 | ||
|
|
f952ac7ffd | ||
|
|
642b0ac18f | ||
|
|
2940a8bff0 | ||
|
|
57c1ce77c1 | ||
|
|
333df15e2b | ||
|
|
216d027e9c | ||
|
|
d92c1f2b23 | ||
|
|
9f827bf647 | ||
|
|
611a2b407b | ||
|
|
15085c2011 | ||
|
|
b9bca6b332 | ||
|
|
20ba886c86 | ||
|
|
882d7ebaf3 | ||
|
|
87da31df0a | ||
|
|
e192742384 | ||
|
|
b1ba37b004 | ||
|
|
aadb4f6756 | ||
|
|
93cdf005ca | ||
|
|
788270a498 | ||
|
|
1953ea9dc8 | ||
|
|
cb5ea7ede9 | ||
|
|
7068f59dff | ||
|
|
c94e77734e | ||
|
|
6d1ef43491 | ||
|
|
ce9a83a655 | ||
|
|
de3f95b532 | ||
|
|
d1a2508053 | ||
|
|
fd3b38f846 | ||
|
|
11161659f5 | ||
|
|
3e93360285 | ||
|
|
c308348b9a | ||
|
|
6f122d79e3 | ||
|
|
6e8de27966 | ||
|
|
81b67aadad | ||
|
|
4793eb4130 | ||
|
|
9b7ff762bb | ||
|
|
c94d8c849a | ||
|
|
ae9a053c59 | ||
|
|
d5b7955ce2 | ||
|
|
585d44b3a6 | ||
|
|
ee8b3baeed | ||
|
|
648bc52813 | ||
|
|
36b5bed256 | ||
|
|
617cf1a6b8 | ||
|
|
332948694e | ||
|
|
87ecf8a2e1 | ||
|
|
bd770fc351 | ||
|
|
d02f7cf6e2 | ||
|
|
b076e006a7 | ||
|
|
43a9239803 | ||
|
|
082211462f | ||
|
|
465dbb955d | ||
|
|
18c276c6af | ||
|
|
384625cec0 | ||
|
|
1bfe1aa6e7 | ||
|
|
de8e9eedc9 | ||
|
|
d6e45bb431 | ||
|
|
556cbcfdf3 | ||
|
|
6824f03815 | ||
|
|
bf6c4d8340 | ||
|
|
746d6fe0d6 | ||
|
|
3c37c4ed92 | ||
|
|
aea787cc8d | ||
|
|
cecd12e329 | ||
|
|
92fc2450f2 | ||
|
|
c07b3e2354 | ||
|
|
ec68099557 | ||
|
|
686dae6bf4 | ||
|
|
268b79fdcb | ||
|
|
06883551f3 | ||
|
|
a6ac664439 | ||
|
|
60fe4467b9 | ||
|
|
87d499c624 | ||
|
|
ed94be81b4 | ||
|
|
d0d3230112 | ||
|
|
3651d5b6f2 | ||
|
|
e993814540 | ||
|
|
e0a6c21077 | ||
|
|
e49e4d8526 | ||
|
|
3a044ff9f6 | ||
|
|
9eb0d0186b | ||
|
|
097824bd9e | ||
|
|
fe26837f36 | ||
|
|
f4accad335 | ||
|
|
11c40853b6 | ||
|
|
4674c02088 | ||
|
|
01b82ecc5b | ||
|
|
29943d9ff1 | ||
|
|
786389518f | ||
|
|
daff6ba68a | ||
|
|
48b8ca0414 | ||
|
|
a3e256de0b | ||
|
|
bebf6053ff | ||
|
|
cf03b1ce7a | ||
|
|
8340b1d526 | ||
|
|
684cf22f14 | ||
|
|
012c76df74 | ||
|
|
d7b0e1c41b | ||
|
|
bc270f533d | ||
|
|
8ba960351f | ||
|
|
d902d9f8dc | ||
|
|
e2c1ea7e05 | ||
|
|
9b35774c1c | ||
|
|
2b2c064df5 | ||
|
|
b7429cd88d | ||
|
|
eed223602d | ||
|
|
ccc8fec627 | ||
|
|
56f1810360 | ||
|
|
f8fe0b7f44 | ||
|
|
fb7bab52a9 | ||
|
|
3250850328 | ||
|
|
44e9304134 | ||
|
|
d9727ae82f | ||
|
|
10f1594e2f | ||
|
|
f31a42f4a6 | ||
|
|
c10e9ce581 | ||
|
|
ab031415d2 | ||
|
|
d3fe0e3344 | ||
|
|
fcd1cdb266 | ||
|
|
fc4370893b | ||
|
|
2c01204b9d | ||
|
|
057f040ab1 | ||
|
|
a19609f803 | ||
|
|
80c83294e2 | ||
|
|
453013fe83 | ||
|
|
7104fe8eb9 | ||
|
|
69aba34f67 | ||
|
|
bfa74978aa |
@@ -75,3 +75,7 @@ VWORLD_SESSION_COOKIE=
|
||||
# True면 대시보드 삭제 버튼이 DB 행과 storage/.../{프로젝트ID}/ 폴더를 실제로 지운다.
|
||||
# 배포 전 반드시 False로 되돌릴 것.
|
||||
PROJECT_DELETE_HARD_ENABLED=True
|
||||
|
||||
# 개발 단계에서 로그인 이메일 인증(OTP)을 건너뛴다 (2026-09-13 사용자 지시).
|
||||
# ENVIRONMENT=development 일 때만 듣는다. 배포 전 반드시 False 로 되돌릴 것.
|
||||
AUTH_OTP_DISABLED=True
|
||||
|
||||
@@ -65,6 +65,8 @@ tmp/
|
||||
config/corridor_node/
|
||||
# 횡단 서버 재계산 번들 — `npm run build:server-calc` 산출물(2026-09-06).
|
||||
config/server_calc_node/
|
||||
# 구조물도 식 풀이 번들 — `npm run build:formula` 산출물(2026-09-13).
|
||||
config/formula_node/
|
||||
|
||||
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
|
||||
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/* =============================================================================
|
||||
* b_svg_zoom_pan.ts
|
||||
* SVG 그래프 한 장의 **확대·축소·팬** — 여러 화면이 한 코드를 쓴다.
|
||||
*
|
||||
* B06 횡단 카드(`B06_Section_UI_Cross_View_Zoom.ts`)에 있던 것을 옮겨 온 것이다
|
||||
* (2026-09-12 사용자 지시 「공용화해줘」). 동작 규칙은 그때 정해진 것을 그대로 지킨다.
|
||||
*
|
||||
* · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼은 그 화면의 고르기·조작 몫이다.
|
||||
* · **더블클릭이면 원배율**로 돌아간다.
|
||||
* · **휠은 화면마다 다르다** — 카드가 수십 장 깔리는 B06 은 휠을 줌에 묶지 않는다
|
||||
* (묶으면 목록을 훑을 수가 없다, 2026-08-02 사용자 확정). 판이 둘뿐인 B05 계획노선
|
||||
* 횡단은 휠 = 줌이다(2026-09-12 사용자 지시 ③). `wheelZoom` 한 칸으로 가른다.
|
||||
*
|
||||
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
|
||||
export interface ZoomPanState {
|
||||
scale: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그려 둔 도형이 실제로 차지하는 범위(원배율 SVG 좌표). 표시 범위 밖에 있어 잘려 있는
|
||||
* **캐시 보유분**까지 포함한다 — 확대 상태의 팬은 이 범위까지 갈 수 있다
|
||||
* (2026-08-23 사용자 지시).
|
||||
*/
|
||||
export interface ContentBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ZoomPanHandle {
|
||||
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
|
||||
zoom: (factor: number) => void;
|
||||
reset: () => void;
|
||||
/** 현재 배율 — 원배율(1)일 때는 부르는 쪽 버튼이 배율 대신 표시 폭을 조절한다. */
|
||||
scale: () => number;
|
||||
}
|
||||
|
||||
/** 축 안쪽 여백(px) — 확대·축소의 중심이자 이동 한계의 기준이 된다. */
|
||||
export interface ZoomPanPad {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface SvgZoomPanOptions {
|
||||
svg: SVGSVGElement;
|
||||
/** 밀고 키울 도형 묶음 — 여기에만 `transform` 이 적힌다. */
|
||||
layer: SVGGElement;
|
||||
/** `viewBox` 의 폭·높이(원배율 SVG 좌표). */
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
pad: ZoomPanPad;
|
||||
/** 배율 상한. 안 주면 8. */
|
||||
maxScale?: number;
|
||||
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
|
||||
initial?: ZoomPanState;
|
||||
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
|
||||
onChange?: (state: ZoomPanState) => void;
|
||||
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||
content?: ContentBounds;
|
||||
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
|
||||
wheelZoom?: boolean;
|
||||
}
|
||||
|
||||
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
|
||||
const WHEEL_IN = 1.15;
|
||||
const WHEEL_OUT = 0.87;
|
||||
|
||||
export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
|
||||
const { svg, layer, widthPx, heightPx, pad, initial, onChange, content } = options;
|
||||
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
|
||||
const plot = {
|
||||
x: pad.left,
|
||||
y: pad.top,
|
||||
width: Math.max(widthPx - pad.left - pad.right, 1),
|
||||
height: Math.max(heightPx - pad.top - pad.bottom, 1),
|
||||
};
|
||||
const maxScale = options.maxScale ?? 8;
|
||||
let scale = initial?.scale ?? 1;
|
||||
let tx = initial?.tx ?? 0;
|
||||
let ty = initial?.ty ?? 0;
|
||||
const applyTransform = (): void => {
|
||||
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||||
onChange?.({ scale, tx, ty });
|
||||
};
|
||||
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
|
||||
// **확대 상태**에서는 한계가 그려진 도형 전체(표시 범위 밖 캐시 보유분 포함)까지 늘어나,
|
||||
// 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(2026-08-23 사용자 지시).
|
||||
const clampAxis = (
|
||||
value: number,
|
||||
start: number,
|
||||
size: number,
|
||||
from: number,
|
||||
to: number,
|
||||
): number =>
|
||||
Math.min(
|
||||
Math.max(value, Math.min(start + size - scale * to, start - scale * from)),
|
||||
Math.max(start + size - scale * to, start - scale * from),
|
||||
);
|
||||
const clampPan = (): void => {
|
||||
// 도형 범위를 모르면 플롯 영역 자신이 한계다(기존 규칙). 알면 **원배율에서도** 그
|
||||
// 범위까지 열어 둔다 — 캐시 보유분이 표시 폭보다 넓으면 1배에서도 끌어다 봐야 한다
|
||||
// (2026-08-23 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단).
|
||||
const bounds = content
|
||||
? {
|
||||
x: Math.min(content.x, plot.x),
|
||||
y: Math.min(content.y, plot.y),
|
||||
right: Math.max(content.x + content.width, plot.x + plot.width),
|
||||
bottom: Math.max(content.y + content.height, plot.y + plot.height),
|
||||
}
|
||||
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
|
||||
tx = clampAxis(tx, plot.x, plot.width, bounds.x, bounds.right);
|
||||
ty = clampAxis(ty, plot.y, plot.height, bounds.y, bounds.bottom);
|
||||
};
|
||||
|
||||
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
|
||||
const zoomAt = (factor: number, cx: number, cy: number): void => {
|
||||
const next = Math.min(maxScale, Math.max(1, scale * factor));
|
||||
tx = cx - ((cx - tx) / scale) * next;
|
||||
ty = cy - ((cy - ty) / scale) * next;
|
||||
scale = next;
|
||||
clampPan();
|
||||
applyTransform();
|
||||
};
|
||||
// 버튼에는 마우스 자리가 없다 — 보이는 **플롯 영역의 중앙**을 붙잡는다.
|
||||
const zoom = (factor: number): void =>
|
||||
zoomAt(factor, plot.x + plot.width / 2, plot.y + plot.height / 2);
|
||||
|
||||
let panning = false;
|
||||
let moved = false;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
|
||||
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
|
||||
svg.addEventListener("mousedown", (event) => {
|
||||
if (event.button === 1) event.preventDefault();
|
||||
});
|
||||
svg.addEventListener("auxclick", (event) => {
|
||||
if (event.button === 1) event.preventDefault();
|
||||
});
|
||||
svg.addEventListener("pointerdown", (event) => {
|
||||
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다.
|
||||
if (event.button !== 1) return;
|
||||
event.preventDefault();
|
||||
panning = true;
|
||||
moved = false;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
svg.classList.add("is-panning");
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
});
|
||||
svg.addEventListener("pointermove", (event) => {
|
||||
if (!panning) return;
|
||||
if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true;
|
||||
// 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다).
|
||||
const rect = svg.getBoundingClientRect();
|
||||
tx += ((event.clientX - lastX) / rect.width) * widthPx;
|
||||
ty += ((event.clientY - lastY) / rect.height) * heightPx;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
clampPan();
|
||||
applyTransform();
|
||||
});
|
||||
const endPan = (event: PointerEvent): void => {
|
||||
if (!panning) return;
|
||||
panning = false;
|
||||
svg.classList.remove("is-panning");
|
||||
try {
|
||||
svg.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
/* 이미 해제됨 */
|
||||
}
|
||||
};
|
||||
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
|
||||
clampPan();
|
||||
applyTransform();
|
||||
}
|
||||
svg.addEventListener("pointerup", endPan);
|
||||
svg.addEventListener("pointercancel", endPan);
|
||||
// 드래그(팬)로 끝난 클릭은 바깥(카드 고르기 등)으로 전파하지 않는다.
|
||||
svg.addEventListener("click", (event) => {
|
||||
if (moved) event.stopPropagation();
|
||||
});
|
||||
if (options.wheelZoom) {
|
||||
svg.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
// 커서 아래 지점이 제자리에 남게 **그 자리**를 붙잡는다.
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
|
||||
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
|
||||
// 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`).
|
||||
zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
}
|
||||
const currentScale = (): number => scale;
|
||||
const reset = (): void => {
|
||||
scale = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
applyTransform();
|
||||
};
|
||||
// 더블클릭 원복.
|
||||
svg.addEventListener("dblclick", (event) => {
|
||||
event.stopPropagation();
|
||||
reset();
|
||||
});
|
||||
return { zoom, reset, scale: currentScale };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""로그인, 재인증, 세션 및 비밀번호 API."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -34,10 +35,12 @@ from common_util.common_util_auth_repository import (
|
||||
)
|
||||
from common_util.common_util_email import send_email_background
|
||||
from common_util.common_util_email_templates import otp_email, security_alert_email
|
||||
from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS
|
||||
from config.config_system import ADMIN_EMAIL, AUTH_OTP_DISABLED, EMAIL_REVERIFY_DAYS
|
||||
|
||||
from .A06_Login_Schema import LoginRequest, OtpVerifyRequest, PasswordChangeRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
|
||||
|
||||
@@ -85,11 +88,20 @@ async def request_login(payload: LoginRequest, request: Request):
|
||||
)
|
||||
new_browser = not trusted_device
|
||||
if periodic_reverify or new_browser:
|
||||
await _send_otp(user, "LOGIN", "로그인")
|
||||
return {
|
||||
"status": "otp_required",
|
||||
"reason": "PERIODIC" if periodic_reverify else "NEW_BROWSER",
|
||||
}
|
||||
# 개발 단계에서는 메일 인증을 건너뛴다 (2026-09-13 사용자 지시).
|
||||
# 이 갈래는 ENVIRONMENT=development + AUTH_OTP_DISABLED=true 에서만 열린다.
|
||||
if AUTH_OTP_DISABLED:
|
||||
logger.warning(
|
||||
"[auth] 개발용 OTP 생략 — %s (%s)",
|
||||
email,
|
||||
"PERIODIC" if periodic_reverify else "NEW_BROWSER",
|
||||
)
|
||||
else:
|
||||
await _send_otp(user, "LOGIN", "로그인")
|
||||
return {
|
||||
"status": "otp_required",
|
||||
"reason": "PERIODIC" if periodic_reverify else "NEW_BROWSER",
|
||||
}
|
||||
return await _finish_login(user, agent, device_token_hash)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""회원가입, 이메일 인증 및 회사 검색 API."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
@@ -27,9 +28,12 @@ from common_util.common_util_auth_repository import (
|
||||
)
|
||||
from common_util.common_util_email import send_email_background
|
||||
from common_util.common_util_email_templates import otp_email
|
||||
from config.config_system import AUTH_OTP_DISABLED
|
||||
|
||||
from .A07_Register_Schema import RegisterRequest, RegisterVerifyRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
|
||||
|
||||
@@ -60,6 +64,10 @@ async def request_registration(payload: RegisterRequest):
|
||||
await replace_otp(user_id, "REGISTER", hash_password(code))
|
||||
subject, html = otp_email(code, "회원가입")
|
||||
send_email_background(email, subject, html)
|
||||
# 개발 단계에서는 메일을 못 받아도 가입을 이어갈 수 있게 코드를 로그에 찍는다
|
||||
# (2026-09-13 사용자 지시). ENVIRONMENT=development + AUTH_OTP_DISABLED=true 에서만.
|
||||
if AUTH_OTP_DISABLED:
|
||||
logger.warning("[auth] 개발용 가입 인증 코드 — %s : %s", email, code)
|
||||
return {"status": "success", "message": "인증 코드를 발송했습니다."}
|
||||
|
||||
|
||||
|
||||
@@ -316,6 +316,25 @@ export async function geocodeAddress(address: string): Promise<{ lat: number; lo
|
||||
return request(`/dashboard/company/geocode?address=${encodeURIComponent(address)}`);
|
||||
}
|
||||
|
||||
/** 주소 후보 한 줄 — 검색해서 골라 넣는 용도. */
|
||||
export interface AddressCandidate {
|
||||
zipcode: string;
|
||||
road: string;
|
||||
parcel: string;
|
||||
building: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
/** 주소를 검색해 후보를 받는다 (도로명 먼저, 없으면 지번). */
|
||||
export async function searchAddress(query: string): Promise<AddressCandidate[]> {
|
||||
const body = await request<{ items?: AddressCandidate[] }>(
|
||||
`/dashboard/company/address/search?query=${encodeURIComponent(query)}`,
|
||||
);
|
||||
return body.items ?? [];
|
||||
}
|
||||
|
||||
/** 회사 정보 수정 — 시스템관리자만 companyId 로 남의 회사를 지정한다. */
|
||||
export function updateCompany(
|
||||
payload: {
|
||||
|
||||
@@ -57,3 +57,60 @@ async def geocode_address(address: str) -> dict[str, Any] | None:
|
||||
async def fetch_base_tile(z: int, x: int, y: int) -> bytes:
|
||||
url = _TILE_URL.format(key=VWORLD_API_KEY, z=z, y=y, x=x)
|
||||
return await asyncio.to_thread(_fetch, url)
|
||||
|
||||
|
||||
_SEARCH_URL = "https://api.vworld.kr/req/search"
|
||||
|
||||
|
||||
def _search_sync(query: str, size: int) -> list[dict[str, Any]]:
|
||||
"""도로명으로 먼저 찾고, 비면 지번으로 다시 찾는다 — 후보를 목록으로 돌려준다."""
|
||||
for category in ("road", "parcel"):
|
||||
params = urllib.parse.urlencode(
|
||||
{
|
||||
"service": "search",
|
||||
"request": "search",
|
||||
"version": "2.0",
|
||||
"crs": "EPSG:4326",
|
||||
"size": str(size),
|
||||
"page": "1",
|
||||
"query": query,
|
||||
"type": "address",
|
||||
"category": category,
|
||||
"format": "json",
|
||||
"errorformat": "json",
|
||||
"key": VWORLD_API_KEY,
|
||||
}
|
||||
)
|
||||
try:
|
||||
body = json.loads(_fetch(f"{_SEARCH_URL}?{params}", timeout=8).decode("utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
result = (body.get("response") or {}).get("result") or {}
|
||||
items = result.get("items") or []
|
||||
found: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
address = item.get("address") or {}
|
||||
point = item.get("point") or {}
|
||||
try:
|
||||
lon = float(point["x"])
|
||||
lat = float(point["y"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
found.append(
|
||||
{
|
||||
"zipcode": address.get("zipcode") or "",
|
||||
"road": address.get("road") or "",
|
||||
"parcel": address.get("parcel") or "",
|
||||
"building": address.get("bldnm") or "",
|
||||
"category": category,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
}
|
||||
)
|
||||
if found:
|
||||
return found
|
||||
return []
|
||||
|
||||
|
||||
async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
|
||||
return await asyncio.to_thread(_search_sync, query.strip(), size)
|
||||
|
||||
@@ -254,6 +254,7 @@ async def list_all_companies() -> list[dict[str, Any]]:
|
||||
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""SELECT c.id, c.name, c.business_registration_number, c.business_status,
|
||||
c.business_address, c.business_owner,
|
||||
c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count,
|
||||
COUNT(DISTINCT p.id) AS project_count
|
||||
FROM companies c
|
||||
|
||||
@@ -25,7 +25,7 @@ from common_util.common_util_project_delete import hard_delete_project
|
||||
from common_util.common_util_storage import read_stored_asset
|
||||
from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED
|
||||
|
||||
from .B01_Dashboard_Map import fetch_base_tile, geocode_address
|
||||
from .B01_Dashboard_Map import fetch_base_tile, geocode_address, search_address
|
||||
from .B01_Dashboard_Repository import (
|
||||
assign_user_company,
|
||||
change_user_role,
|
||||
@@ -257,6 +257,16 @@ async def company_geocode(
|
||||
return {"status": "success", **point}
|
||||
|
||||
|
||||
@router.get("/company/address/search")
|
||||
async def company_address_search(
|
||||
query: str = Query(min_length=2, max_length=200),
|
||||
session: dict[str, Any] = Depends(verify_session),
|
||||
):
|
||||
"""주소 후보를 찾아 준다 — 회사 등록·수정 화면에서 골라 넣는 용도."""
|
||||
_ = session
|
||||
return {"status": "success", "items": await search_address(query)}
|
||||
|
||||
|
||||
@router.get("/map/tile/{z}/{x}/{y}")
|
||||
async def map_tile(
|
||||
z: int = Path(ge=0, le=19),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { searchAddress, type AddressCandidate } from "./B01_Dashboard_Api_Fetch";
|
||||
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
|
||||
import { L } from "./B01_Dashboard_UI_Common";
|
||||
|
||||
/**
|
||||
* 회사 주소 입력칸 — 검색해서 고르는 방식 (2026-09-13 사용자 지시).
|
||||
*
|
||||
* 손으로 정확히 치지 않아도 되게, 일부만 넣어 후보를 받고 고른다.
|
||||
* 고른 즉시 좌표를 알므로 지도를 다시 찾지 않고 바로 그린다.
|
||||
* 저장은 지금대로 한 줄 — 「기본주소 + 상세주소」. 우편번호는 화면 확인용.
|
||||
*
|
||||
* 줄 배치는 라벨·입력·버튼을 한 줄에 세운다(B06 횡단 설정과 같은 꼴) — ui-field 가
|
||||
* 기본이 세로라 그대로 두면 오류 슬롯 높이만큼 버튼이 밀려 수평이 어긋난다.
|
||||
* 그래서 오류 슬롯은 접고, 잘못 넣은 것은 토스트로 알린다.
|
||||
*/
|
||||
|
||||
export interface AddressFieldHandle {
|
||||
root: HTMLElement;
|
||||
/** 저장할 한 줄 주소. 비면 null. */
|
||||
value: () => string | null;
|
||||
}
|
||||
|
||||
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
|
||||
function candidateLine(item: AddressCandidate): string {
|
||||
const main = item.category === "parcel" ? item.parcel : item.road;
|
||||
return main || item.road || item.parcel;
|
||||
}
|
||||
|
||||
function candidateHint(item: AddressCandidate): string {
|
||||
const other = item.category === "parcel" ? item.road : item.parcel;
|
||||
return [item.building, other, item.zipcode].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** createButton 은 글자를 span 에 담는다 — textContent 로 덮으면 그 껍질이 사라진다. */
|
||||
function setButtonLabel(button: HTMLButtonElement, label: string): void {
|
||||
const slot = button.querySelector(".ui-btn__label");
|
||||
if (slot) slot.textContent = label;
|
||||
else button.textContent = label;
|
||||
}
|
||||
|
||||
function row(...parts: HTMLElement[]): HTMLElement {
|
||||
const line = document.createElement("div");
|
||||
line.className = "b01-dashboard__address-row";
|
||||
line.append(...parts);
|
||||
return line;
|
||||
}
|
||||
|
||||
export function buildAddressField(initial: string | null): AddressFieldHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b01-dashboard__address";
|
||||
|
||||
// 1) 검색 줄 — 일부만 넣어도 후보가 나온다.
|
||||
const query = createInputField({
|
||||
label: L("B01_Dashboard_Field_Address"),
|
||||
placeholder: "도로명·지번·건물명 일부 (예: 판교역로 235)",
|
||||
});
|
||||
const findBtn = createButton({
|
||||
label: "주소 찾기",
|
||||
variant: "ghost",
|
||||
onClick: () => void runSearch(),
|
||||
});
|
||||
const manualBtn = createButton({
|
||||
label: "직접 입력",
|
||||
variant: "ghost",
|
||||
onClick: function onB01_Address_Manual_Click() {
|
||||
manual = !manual;
|
||||
setButtonLabel(manualBtn, manual ? "검색으로" : "직접 입력");
|
||||
base.input.readOnly = !manual;
|
||||
query.root.hidden = manual;
|
||||
findBtn.hidden = manual;
|
||||
mapBtn.hidden = !manual;
|
||||
if (manual) base.input.focus();
|
||||
},
|
||||
});
|
||||
const searchRow = row(query.root, findBtn, manualBtn);
|
||||
|
||||
// 2) 후보 목록 — 고르면 사라진다.
|
||||
const list = document.createElement("div");
|
||||
list.className = "b01-dashboard__address-list";
|
||||
list.hidden = true;
|
||||
|
||||
// 3) 고른 주소 + 상세주소.
|
||||
const base = createInputField({ label: "기본주소", value: initial ?? "" });
|
||||
base.input.readOnly = true;
|
||||
const zip = document.createElement("span");
|
||||
zip.className = "b01-dashboard__address-zip";
|
||||
zip.hidden = true;
|
||||
const detail = createInputField({ label: "상세주소", placeholder: "동·층·호" });
|
||||
|
||||
// 4) 지도 — 고른 즉시 그린다. 직접 입력일 때만 버튼으로 확인한다.
|
||||
const map = buildAddressMap();
|
||||
const mapBtn = createButton({
|
||||
label: "지도 확인",
|
||||
variant: "ghost",
|
||||
onClick: async function onB01_Address_Map_Click() {
|
||||
await map.show(base.input.value.trim());
|
||||
},
|
||||
});
|
||||
mapBtn.hidden = true;
|
||||
|
||||
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
|
||||
|
||||
let manual = false;
|
||||
|
||||
function pick(item: AddressCandidate): void {
|
||||
base.input.value = candidateLine(item);
|
||||
zip.textContent = item.zipcode ? `우편번호 ${item.zipcode}` : "";
|
||||
zip.hidden = !item.zipcode;
|
||||
list.hidden = true;
|
||||
list.innerHTML = "";
|
||||
map.showPoint(item.lat, item.lon);
|
||||
detail.input.focus();
|
||||
}
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
const text = query.input.value.trim();
|
||||
if (text.length < 2) {
|
||||
showToast("주소를 두 글자 이상 넣으십시오.", "error");
|
||||
return;
|
||||
}
|
||||
findBtn.disabled = true;
|
||||
setButtonLabel(findBtn, "찾는 중…");
|
||||
try {
|
||||
const items = await searchAddress(text);
|
||||
list.innerHTML = "";
|
||||
list.hidden = false;
|
||||
if (items.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b01-dashboard__modal-text";
|
||||
empty.textContent = "찾은 주소가 없습니다. 다른 낱말로 넣거나 「직접 입력」을 쓰십시오.";
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const item of items) {
|
||||
const option = document.createElement("button");
|
||||
option.type = "button";
|
||||
option.className = "b01-dashboard__address-item";
|
||||
const line = document.createElement("strong");
|
||||
line.textContent = candidateLine(item);
|
||||
const hint = document.createElement("small");
|
||||
hint.textContent = candidateHint(item);
|
||||
option.append(line, hint);
|
||||
option.addEventListener("click", () => pick(item));
|
||||
list.append(option);
|
||||
}
|
||||
} catch {
|
||||
showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하십시오.", "error");
|
||||
} finally {
|
||||
findBtn.disabled = false;
|
||||
setButtonLabel(findBtn, "주소 찾기");
|
||||
}
|
||||
}
|
||||
|
||||
// 검색칸에서 Enter — 모달이 닫히지 않게 막고 찾기만 한다.
|
||||
query.input.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
void runSearch();
|
||||
});
|
||||
|
||||
// 이미 있는 주소는 열자마자 지도로 보여 준다.
|
||||
if (initial && initial.trim()) void map.show(initial.trim());
|
||||
|
||||
return {
|
||||
root,
|
||||
value: () => {
|
||||
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
|
||||
return merged || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -157,6 +157,16 @@ export function attachModalDismiss(
|
||||
if (ok) close();
|
||||
};
|
||||
|
||||
// 바탕(패널 바깥) 위에서 굴린 휠이 뒤 화면을 움직이지 않게 막는다
|
||||
// (2026-09-13 사용자 지시 — 모달을 열어 둔 채 대시보드가 함께 굴렀다).
|
||||
modal.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (event.target === modal) event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
|
||||
// 패널 안에서 시작한 드래그가 바깥에서 끝나도 닫히지 않게 누른 자리까지 본다.
|
||||
let downOnOverlay = false;
|
||||
modal.addEventListener("mousedown", (event) => {
|
||||
|
||||
@@ -44,6 +44,8 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement {
|
||||
createTag(`${state.company.name} (${state.user.status})`, "success"),
|
||||
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
|
||||
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
|
||||
// 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시).
|
||||
text(`${L("B01_Dashboard_Field_Address")}: ${state.company.business_address ?? "-"}`),
|
||||
);
|
||||
// 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다.
|
||||
if (state.user.role !== "USER" && state.company) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { geocodeAddress } from "./B01_Dashboard_Api_Fetch";
|
||||
|
||||
/**
|
||||
@@ -6,13 +7,23 @@ import { geocodeAddress } from "./B01_Dashboard_Api_Fetch";
|
||||
*
|
||||
* 지도 라이브러리를 얹지 않는다 — 배경지도 타일 3×3 장을 붙이고 가운데에 표식만 찍는다.
|
||||
* 등록·수정 화면에서 "이 주소가 여기 맞나" 를 눈으로 보는 것이 목적이다.
|
||||
* 건물을 알아볼 수 있어야 하므로 기본 배율을 17 로 두고 +·- 로 두 단계씩 움직인다
|
||||
* (2026-09-13 사용자 지시 — 15 는 너무 멀었다).
|
||||
*/
|
||||
const ZOOM = 15;
|
||||
const DEFAULT_ZOOM = 17;
|
||||
const MIN_ZOOM = 13;
|
||||
const MAX_ZOOM = 18;
|
||||
const TILE = 256;
|
||||
const GRID = 3;
|
||||
/** 타일 판은 CSS 에서 절반으로 줄여 붙인다 — 표식 자리도 같은 비율로 잡는다. */
|
||||
const SCALE = 0.5;
|
||||
|
||||
function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number; dy: number } {
|
||||
const n = 2 ** ZOOM;
|
||||
function tileIndex(
|
||||
lat: number,
|
||||
lon: number,
|
||||
zoom: number,
|
||||
): { x: number; y: number; dx: number; dy: number } {
|
||||
const n = 2 ** zoom;
|
||||
const rad = (lat * Math.PI) / 180;
|
||||
const fx = ((lon + 180) / 360) * n;
|
||||
const fy = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
|
||||
@@ -22,35 +33,59 @@ function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number
|
||||
export function buildAddressMap(): {
|
||||
root: HTMLElement;
|
||||
show: (address: string) => Promise<void>;
|
||||
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
|
||||
showPoint: (lat: number, lon: number) => void;
|
||||
} {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b01-dashboard__map";
|
||||
const note = document.createElement("p");
|
||||
note.className = "b01-dashboard__modal-text";
|
||||
note.textContent = "주소를 넣고 「지도 확인」을 누르십시오.";
|
||||
root.append(note);
|
||||
|
||||
const show = async (address: string): Promise<void> => {
|
||||
root.innerHTML = "";
|
||||
if (!address.trim()) {
|
||||
note.textContent = "주소를 먼저 입력하십시오.";
|
||||
root.append(note);
|
||||
return;
|
||||
}
|
||||
const point = await geocodeAddress(address).catch(() => null);
|
||||
if (!point) {
|
||||
note.textContent = "그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.";
|
||||
root.append(note);
|
||||
return;
|
||||
}
|
||||
const center = tileIndex(point.lat, point.lon);
|
||||
const zoomOut = createButton({
|
||||
label: "-",
|
||||
variant: "ghost",
|
||||
onClick: () => step(-1),
|
||||
});
|
||||
const zoomIn = createButton({
|
||||
label: "+",
|
||||
variant: "ghost",
|
||||
onClick: () => step(1),
|
||||
});
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "b01-dashboard__map-zoom";
|
||||
controls.append(zoomOut, zoomIn);
|
||||
controls.hidden = true;
|
||||
|
||||
const stage = document.createElement("div");
|
||||
root.append(note, controls, stage);
|
||||
|
||||
let zoom = DEFAULT_ZOOM;
|
||||
let last: { lat: number; lon: number } | null = null;
|
||||
|
||||
function step(delta: number): void {
|
||||
if (!last) return;
|
||||
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom + delta));
|
||||
if (next === zoom) return;
|
||||
zoom = next;
|
||||
draw(last.lat, last.lon);
|
||||
}
|
||||
|
||||
function draw(lat: number, lon: number): void {
|
||||
last = { lat, lon };
|
||||
note.hidden = true;
|
||||
controls.hidden = false;
|
||||
zoomOut.disabled = zoom <= MIN_ZOOM;
|
||||
zoomIn.disabled = zoom >= MAX_ZOOM;
|
||||
stage.innerHTML = "";
|
||||
const center = tileIndex(lat, lon, zoom);
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "b01-dashboard__map-grid";
|
||||
const half = Math.floor(GRID / 2);
|
||||
for (let row = -half; row <= half; row += 1) {
|
||||
for (let col = -half; col <= half; col += 1) {
|
||||
const img = document.createElement("img");
|
||||
img.src = `${API_BASE_URL}/dashboard/map/tile/${ZOOM}/${center.x + col}/${center.y + row}`;
|
||||
img.src = `${API_BASE_URL}/dashboard/map/tile/${zoom}/${center.x + col}/${center.y + row}`;
|
||||
img.width = TILE;
|
||||
img.height = TILE;
|
||||
img.alt = "";
|
||||
@@ -59,14 +94,34 @@ export function buildAddressMap(): {
|
||||
}
|
||||
const marker = document.createElement("span");
|
||||
marker.className = "b01-dashboard__map-marker";
|
||||
// 타일 판은 CSS 에서 절반으로 줄여 붙이므로 표식 자리도 절반으로 잡는다.
|
||||
marker.style.left = `${(half + center.dx) * TILE * 0.5}px`;
|
||||
marker.style.top = `${(half + center.dy) * TILE * 0.5}px`;
|
||||
marker.style.left = `${(half + center.dx) * TILE * SCALE}px`;
|
||||
marker.style.top = `${(half + center.dy) * TILE * SCALE}px`;
|
||||
const frame = document.createElement("div");
|
||||
frame.className = "b01-dashboard__map-frame";
|
||||
frame.append(grid, marker);
|
||||
root.append(frame);
|
||||
stage.append(frame);
|
||||
}
|
||||
|
||||
function fail(message: string): void {
|
||||
last = null;
|
||||
stage.innerHTML = "";
|
||||
controls.hidden = true;
|
||||
note.hidden = false;
|
||||
note.textContent = message;
|
||||
}
|
||||
|
||||
const show = async (address: string): Promise<void> => {
|
||||
if (!address.trim()) {
|
||||
fail("주소를 먼저 입력하십시오.");
|
||||
return;
|
||||
}
|
||||
const point = await geocodeAddress(address).catch(() => null);
|
||||
if (!point) {
|
||||
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
|
||||
return;
|
||||
}
|
||||
draw(point.lat, point.lon);
|
||||
};
|
||||
|
||||
return { root, show };
|
||||
return { root, show, showPoint: draw };
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
type Member,
|
||||
} from "./B01_Dashboard_Api_Fetch";
|
||||
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
|
||||
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
|
||||
import { buildAddressField } from "./B01_Dashboard_UI_AddressField";
|
||||
import {
|
||||
attachModalDismiss,
|
||||
buildUserFields,
|
||||
@@ -438,28 +438,15 @@ function companyFields(company?: CompanyInfo): {
|
||||
const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" });
|
||||
logo.input.type = "file";
|
||||
logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
|
||||
const address = createInputField({
|
||||
label: L("B01_Dashboard_Field_Address"),
|
||||
value: company?.business_address ?? "",
|
||||
});
|
||||
// 주소가 맞는 자리인지 지도로 확인한다 (2026-09-06 사용자 지시).
|
||||
const map = buildAddressMap();
|
||||
const mapBtn = createButton({
|
||||
label: "지도 확인",
|
||||
variant: "ghost",
|
||||
onClick: async function onB01_Company_Map_Click() {
|
||||
await map.show(address.input.value.trim());
|
||||
},
|
||||
});
|
||||
const addressRow = document.createElement("div");
|
||||
addressRow.append(address.root, mapBtn, map.root);
|
||||
// 주소는 검색해서 고른다 — 손으로 정확히 치지 않아도 되게 (2026-09-13 사용자 지시).
|
||||
const address = buildAddressField(company?.business_address ?? null);
|
||||
|
||||
return {
|
||||
rows: [number.root, name.root, owner.root, logo.root, addressRow],
|
||||
rows: [number.root, name.root, owner.root, logo.root, address.root],
|
||||
values: () => ({
|
||||
name: name.input.value.trim(),
|
||||
business_registration_number: number.input.value.trim(),
|
||||
business_address: address.input.value.trim() || null,
|
||||
business_address: address.value(),
|
||||
business_owner: owner.input.value.trim() || null,
|
||||
}),
|
||||
logoFile: () => logo.input.files?.[0],
|
||||
|
||||
@@ -186,6 +186,8 @@
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 2 * var(--spacing-24));
|
||||
overflow-y: auto;
|
||||
/* 패널 끝까지 굴려도 뒤 대시보드로 넘어가지 않게 가둔다 (2026-09-13 사용자 지시). */
|
||||
overscroll-behavior: contain;
|
||||
background: var(--color-surface-raised);
|
||||
border-radius: var(--radius-cards);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -291,6 +293,99 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 회사 주소 입력 — 검색해서 고르는 칸 (2026-09-13).
|
||||
라벨·입력·버튼을 한 줄에 세운다. ui-field 가 기본이 세로(라벨 위·입력 아래·오류 슬롯)라
|
||||
그대로 두면 버튼이 오류 슬롯 높이만큼 밀려 수평이 어긋난다 — 이 칸에서만 가로로 눕힌다. */
|
||||
.b01-dashboard__address {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-field {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8, 8px);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__label {
|
||||
flex: 0 0 64px;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-row > .ui-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-zip {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-muted, #666);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 8px);
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8, 8px);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item:hover,
|
||||
.b01-dashboard__address-item:focus-visible {
|
||||
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
|
||||
}
|
||||
|
||||
.b01-dashboard__address-item small {
|
||||
color: var(--color-text-muted, #666);
|
||||
}
|
||||
|
||||
.b01-dashboard__map-zoom {
|
||||
display: flex;
|
||||
gap: var(--spacing-8, 8px);
|
||||
margin-bottom: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */
|
||||
.b01-dashboard__map-frame {
|
||||
position: relative;
|
||||
|
||||
@@ -202,6 +202,9 @@ export interface StationTickOptions {
|
||||
toScreen: (x: number, y: number) => [number, number];
|
||||
/** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */
|
||||
avoidChainages?: ReadonlyArray<number>;
|
||||
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다.
|
||||
* 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */
|
||||
uprightRad?: number;
|
||||
}
|
||||
|
||||
export function drawStationTicks(
|
||||
@@ -274,11 +277,18 @@ export function drawStationTicks(
|
||||
);
|
||||
if (collides) continue;
|
||||
drawn.push({ x: lx, y: ly, half });
|
||||
context.save();
|
||||
if (options.uprightRad) {
|
||||
context.translate(lx, ly);
|
||||
context.rotate(options.uprightRad);
|
||||
context.translate(-lx, -ly);
|
||||
}
|
||||
// 배경을 깔아 등고선 위에서도 읽히게 한다.
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.78)";
|
||||
context.fillRect(lx - half, ly - 8, width, 16);
|
||||
context.fillStyle = "#222222";
|
||||
context.fillText(label, lx, ly);
|
||||
context.restore();
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
@@ -42,14 +42,14 @@ export const ROUTE_LINE_WIDTH = 2.4;
|
||||
* 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다.
|
||||
* line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다.
|
||||
*/
|
||||
type PreparedPart = {
|
||||
export type PreparedPart = {
|
||||
coords: Float64Array;
|
||||
closed: boolean;
|
||||
weights: Float64Array | null;
|
||||
};
|
||||
|
||||
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
|
||||
type PreparedFeature = {
|
||||
export type PreparedFeature = {
|
||||
kind: "line" | "point";
|
||||
parts: PreparedPart[];
|
||||
minX: number;
|
||||
@@ -60,6 +60,8 @@ type PreparedFeature = {
|
||||
labelAnchorX: number;
|
||||
labelAnchorY: number;
|
||||
labelText: string | null;
|
||||
/** 그 라벨의 표고(m). 어느 줄을 실제로 낼지는 `drawPreparedLabels` 가 줌을 보고 고른다. */
|
||||
labelValue: number | null;
|
||||
};
|
||||
|
||||
export type PreparedLayer = {
|
||||
@@ -214,226 +216,81 @@ export function computeRouteView(
|
||||
};
|
||||
}
|
||||
|
||||
function isPoint(value: unknown): value is [number, number] {
|
||||
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
|
||||
}
|
||||
|
||||
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
|
||||
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
|
||||
if (!Array.isArray(ring) || ring.length === 0) return null;
|
||||
const coords = new Float64Array(ring.length * 2);
|
||||
let count = 0;
|
||||
for (const point of ring) {
|
||||
if (!isPoint(point)) continue;
|
||||
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
|
||||
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
|
||||
count += 1;
|
||||
}
|
||||
if (count === 0) return null;
|
||||
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
|
||||
}
|
||||
|
||||
function collectParts(
|
||||
geometry: GeoJsonGeometry,
|
||||
normalizer: Normalizer,
|
||||
parts: PreparedPart[],
|
||||
): "line" | "point" {
|
||||
const coordinates = geometry.coordinates;
|
||||
if (!Array.isArray(coordinates)) return "line";
|
||||
const push = (ring: unknown, closed: boolean): void => {
|
||||
const projected = projectRing(ring, normalizer);
|
||||
if (projected) parts.push({ coords: projected, closed, weights: null });
|
||||
};
|
||||
switch (geometry.type) {
|
||||
case "Point":
|
||||
push([coordinates], false);
|
||||
return "point";
|
||||
case "MultiPoint":
|
||||
push(coordinates, false);
|
||||
return "point";
|
||||
case "LineString":
|
||||
push(coordinates, false);
|
||||
return "line";
|
||||
case "MultiLineString":
|
||||
for (const line of coordinates) push(line, false);
|
||||
return "line";
|
||||
case "Polygon":
|
||||
for (const ring of coordinates) push(ring, true);
|
||||
return "line";
|
||||
case "MultiPolygon":
|
||||
for (const polygon of coordinates) {
|
||||
if (!Array.isArray(polygon)) continue;
|
||||
for (const ring of polygon) push(ring, true);
|
||||
}
|
||||
return "line";
|
||||
default:
|
||||
return "line";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
|
||||
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
|
||||
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
|
||||
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
|
||||
*/
|
||||
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
|
||||
const n = coords.length / 2;
|
||||
const weights = new Float64Array(n);
|
||||
weights[0] = Infinity;
|
||||
weights[n - 1] = Infinity;
|
||||
if (n <= 2) return weights;
|
||||
const stack: number[] = [0, n - 1];
|
||||
const caps: number[] = [Infinity];
|
||||
while (stack.length) {
|
||||
const last = stack.pop()!;
|
||||
const first = stack.pop()!;
|
||||
const cap = caps.pop()!;
|
||||
if (last - first < 2) continue;
|
||||
const ax = coords[first * 2];
|
||||
const ay = coords[first * 2 + 1] / aspect;
|
||||
const bx = coords[last * 2];
|
||||
const by = coords[last * 2 + 1] / aspect;
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
let maxDist = -1;
|
||||
let maxIndex = -1;
|
||||
for (let i = first + 1; i < last; i += 1) {
|
||||
const px = coords[i * 2] - ax;
|
||||
const py = coords[i * 2 + 1] / aspect - ay;
|
||||
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
|
||||
if (dist > maxDist) {
|
||||
maxDist = dist;
|
||||
maxIndex = i;
|
||||
}
|
||||
}
|
||||
const weight = Math.min(maxDist, cap);
|
||||
weights[maxIndex] = weight;
|
||||
stack.push(first, maxIndex, maxIndex, last);
|
||||
caps.push(weight, weight);
|
||||
}
|
||||
return weights;
|
||||
}
|
||||
|
||||
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
|
||||
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
|
||||
const coords = geometry.coordinates;
|
||||
if (!Array.isArray(coords)) return null;
|
||||
const line =
|
||||
geometry.type === "LineString"
|
||||
? coords
|
||||
: geometry.type === "MultiLineString"
|
||||
? coords[0]
|
||||
: null;
|
||||
if (!Array.isArray(line) || line.length === 0) return null;
|
||||
const mid = line[Math.floor(line.length / 2)];
|
||||
if (!isPoint(mid)) return null;
|
||||
return [
|
||||
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
|
||||
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoJSON 컬렉션 1개를 사전 투영한다.
|
||||
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
|
||||
*/
|
||||
export function prepareLayer(
|
||||
collection: GeoJsonCollection | undefined,
|
||||
normalizer: Normalizer,
|
||||
labelKeys?: string[],
|
||||
): PreparedLayer {
|
||||
const features: PreparedFeature[] = [];
|
||||
for (const feature of collection?.features ?? []) {
|
||||
if (!feature.geometry) continue;
|
||||
const parts: PreparedPart[] = [];
|
||||
const kind = collectParts(feature.geometry, normalizer, parts);
|
||||
if (parts.length === 0) continue;
|
||||
if (kind === "line") {
|
||||
for (const part of parts) {
|
||||
if (part.coords.length < 6) continue;
|
||||
part.weights = computeDpWeights(part.coords, normalizer.aspect);
|
||||
}
|
||||
}
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const part of parts) {
|
||||
/** 화면 px 에 가장 가까운 선 피처의 자리. 그만큼 안에 없으면 -1(계획서 0-9 ⑦). */
|
||||
export function hitPreparedLayer(
|
||||
layer: PreparedLayer,
|
||||
view: ViewState,
|
||||
px: number,
|
||||
py: number,
|
||||
tolerancePx: number,
|
||||
everyM?: number,
|
||||
): number {
|
||||
const affine = affineOf(view);
|
||||
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
|
||||
let best = -1;
|
||||
let bestDistance = tolerancePx;
|
||||
layer.features.forEach((feature, index) => {
|
||||
if (feature.kind !== "line") return;
|
||||
// **그리지 않은 줄은 집히지도 않는다** — 안 보이는 등고선이 골라지면 없던 선이 튀어나온다.
|
||||
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) return;
|
||||
// 화면 밖·멀리 있는 피처는 바운딩박스에서 먼저 떨군다 — 도엽 등고선은 수천 가닥이다.
|
||||
const x0 = feature.minX * affine.ax + affine.bx - tolerancePx;
|
||||
const x1 = feature.maxX * affine.ax + affine.bx + tolerancePx;
|
||||
const y0 = feature.minY * affine.ay + affine.by - tolerancePx;
|
||||
const y1 = feature.maxY * affine.ay + affine.by + tolerancePx;
|
||||
if (px < x0 || px > x1 || py < y0 || py > y1) return;
|
||||
for (const part of feature.parts) {
|
||||
const coords = part.coords;
|
||||
let lastX = NaN;
|
||||
let lastY = NaN;
|
||||
// 그릴 때와 **같은 LOD** 로 훑는다 — 화면에 없는 정점에 걸리면 눈과 손이 어긋난다.
|
||||
const tolerance = LOD_PX / affine.ax;
|
||||
for (let i = 0; i < coords.length; i += 2) {
|
||||
const x = coords[i];
|
||||
const y = coords[i + 1];
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
let labelText: string | null = null;
|
||||
let labelAnchorX = 0;
|
||||
let labelAnchorY = 0;
|
||||
if (labelKeys && labelKeys.length > 0) {
|
||||
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
|
||||
const elevation = typeof raw === "number" ? raw : Number(raw);
|
||||
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
|
||||
if (Number.isFinite(elevation) && elevation % 25 === 0) {
|
||||
const anchor = labelAnchorOf(feature.geometry, normalizer);
|
||||
if (anchor) {
|
||||
labelText = String(elevation);
|
||||
labelAnchorX = anchor[0];
|
||||
labelAnchorY = anchor[1];
|
||||
if (part.weights && part.weights[i / 2] < tolerance) continue;
|
||||
const x = coords[i] * affine.ax + affine.bx;
|
||||
const y = coords[i + 1] * affine.ay + affine.by;
|
||||
if (Number.isFinite(lastX)) {
|
||||
const distance = pointSegmentDistance(px, py, lastX, lastY, x, y);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = index;
|
||||
}
|
||||
}
|
||||
lastX = x;
|
||||
lastY = y;
|
||||
}
|
||||
}
|
||||
features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText });
|
||||
}
|
||||
return { features };
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
|
||||
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
|
||||
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
|
||||
*/
|
||||
export function prepareMetricPolyline(
|
||||
points: ReadonlyArray<{ x: number; y: number }>,
|
||||
meta: VWorldMeta,
|
||||
): PreparedLayer {
|
||||
if (points.length < 2) return { features: [] };
|
||||
const widthMeters = meta.width_meters || 1;
|
||||
const heightMeters = meta.height_meters || 1;
|
||||
const coords = new Float64Array(points.length * 2);
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
points.forEach((point, index) => {
|
||||
const nx = (point.x - meta.x_min) / widthMeters;
|
||||
const ny = 1 - (point.y - meta.y_min) / heightMeters;
|
||||
coords[index * 2] = nx;
|
||||
coords[index * 2 + 1] = ny;
|
||||
if (nx < minX) minX = nx;
|
||||
if (nx > maxX) maxX = nx;
|
||||
if (ny < minY) minY = ny;
|
||||
if (ny > maxY) maxY = ny;
|
||||
});
|
||||
return {
|
||||
features: [
|
||||
{
|
||||
kind: "line",
|
||||
parts: [{ coords, closed: false, weights: null }],
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
labelAnchorX: 0,
|
||||
labelAnchorY: 0,
|
||||
labelText: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
/** 레이어 안의 피처 하나만 다시 그린다 — 고른 등고선을 도드라지게 할 때 쓴다. */
|
||||
export function drawPreparedFeature(
|
||||
context: CanvasRenderingContext2D,
|
||||
layer: PreparedLayer,
|
||||
index: number,
|
||||
view: ViewState,
|
||||
): void {
|
||||
const feature = layer.features[index];
|
||||
if (!feature || feature.kind !== "line") return;
|
||||
drawLineParts(context, feature, affineOf(view));
|
||||
}
|
||||
|
||||
/** 점과 선분 사이 거리(px). */
|
||||
function pointSegmentDistance(
|
||||
px: number,
|
||||
py: number,
|
||||
ax: number,
|
||||
ay: number,
|
||||
bx: number,
|
||||
by: number,
|
||||
): number {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lengthSquared = dx * dx + dy * dy;
|
||||
if (lengthSquared <= 1e-9) return Math.hypot(px - ax, py - ay);
|
||||
const ratio = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
|
||||
return Math.hypot(px - (ax + dx * ratio), py - (ay + dy * ratio));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,6 +420,9 @@ function drawPointParts(
|
||||
|
||||
/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */
|
||||
const CULL_MARGIN = 32;
|
||||
/** 등고 라벨끼리 이만큼(px)은 떨어져야 둘 다 낸다 — 가로 여백과 줄 높이. */
|
||||
const LABEL_GAP_PX = 10;
|
||||
const LABEL_ROW_PX = 14;
|
||||
|
||||
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
|
||||
const margin = CULL_MARGIN;
|
||||
@@ -578,41 +438,142 @@ function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): b
|
||||
);
|
||||
}
|
||||
|
||||
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */
|
||||
/** 레이어가 차지하는 **화면 사각형**(px). 피처가 없으면 null.
|
||||
*
|
||||
* LAS 등고선처럼 도엽보다 좁은 자료 위에 다른 레이어를 겹칠 때, 그 자료가 있는 데까지만
|
||||
* 그리려고 쓴다(계획서 0-9 ⑮ — 세류선이 등고선 밖까지 뻗던 자리). */
|
||||
export function layerScreenBounds(
|
||||
layer: PreparedLayer,
|
||||
view: ViewState,
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
if (layer.features.length === 0) return null;
|
||||
const affine = affineOf(view);
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const feature of layer.features) {
|
||||
minX = Math.min(minX, feature.minX);
|
||||
minY = Math.min(minY, feature.minY);
|
||||
maxX = Math.max(maxX, feature.maxX);
|
||||
maxY = Math.max(maxY, feature.maxY);
|
||||
}
|
||||
const x0 = minX * affine.ax + affine.bx;
|
||||
const x1 = maxX * affine.ax + affine.bx;
|
||||
const y0 = minY * affine.ay + affine.by;
|
||||
const y1 = maxY * affine.ay + affine.by;
|
||||
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
||||
}
|
||||
|
||||
/** 등고선을 몇 m 마다 낼지 고를 때 훑는 배수. 성긴 쪽으로 한 칸씩 물러난다. */
|
||||
const LEVEL_STEP_MULTIPLES = [1, 2, 5, 10, 20, 50, 100];
|
||||
/** 한 화면에 둘 등고선 가닥 수의 어림 상한 — 이보다 많으면 한 칸 성글게 간다. */
|
||||
const LEVEL_BUDGET = 350;
|
||||
|
||||
/**
|
||||
* 지금 화면에 **몇 m 간격**으로 등고선을 낼지 고른다.
|
||||
*
|
||||
* 간격을 줌으로만 정하면 가파른 데서는 여전히 선이 뭉개지고 완만한 데서는 너무 성기다.
|
||||
* 그래서 **지금 화면에 실제로 들어오는 가닥 수**를 세어 상한을 넘지 않는 가장 촘촘한 간격을
|
||||
* 고른다 — 확대하면 저절로 촘촘해지고 물러나면 성겨진다(2026-09-12 실화면: 1m LAS 등고선을
|
||||
* 다 그리면 지형이 선으로 덮였다).
|
||||
*/
|
||||
export function pickLevelStep(
|
||||
layer: PreparedLayer,
|
||||
view: ViewState,
|
||||
intervalM: number,
|
||||
budget = LEVEL_BUDGET,
|
||||
): number {
|
||||
const interval = intervalM > 0 ? intervalM : 1;
|
||||
const affine = affineOf(view);
|
||||
let step = interval * LEVEL_STEP_MULTIPLES[LEVEL_STEP_MULTIPLES.length - 1];
|
||||
for (const multiple of LEVEL_STEP_MULTIPLES) {
|
||||
const candidate = interval * multiple;
|
||||
let count = 0;
|
||||
for (const feature of layer.features) {
|
||||
if (feature.labelValue !== null && feature.labelValue % candidate !== 0) continue;
|
||||
if (!isVisible(feature, affine, view)) continue;
|
||||
count += 1;
|
||||
if (count > budget) break;
|
||||
}
|
||||
if (count <= budget) return candidate;
|
||||
step = candidate;
|
||||
}
|
||||
return step;
|
||||
}
|
||||
|
||||
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다.
|
||||
*
|
||||
* `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이
|
||||
* 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면
|
||||
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. */
|
||||
export function drawPreparedLayer(
|
||||
context: CanvasRenderingContext2D,
|
||||
layer: PreparedLayer,
|
||||
view: ViewState,
|
||||
marker: MarkerKind,
|
||||
everyM?: number,
|
||||
): void {
|
||||
const affine = affineOf(view);
|
||||
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
|
||||
for (const feature of layer.features) {
|
||||
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) continue;
|
||||
if (!isVisible(feature, affine, view)) continue;
|
||||
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
|
||||
else drawLineParts(context, feature, affine);
|
||||
}
|
||||
}
|
||||
|
||||
/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */
|
||||
/** 사전 계산된 등고 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다.
|
||||
*
|
||||
* `everyM` 은 **몇 m 마다 한 줄을 라벨할지**다. 기본 25m(계곡선)는 B04 지도가 쓰던 값 그대로다
|
||||
* — 전부 내면 화면이 숫자로 뒤덮인다. 확대가 큰 화면은 더 작은 값을 넘겨 촘촘히 낸다. */
|
||||
export function drawPreparedLabels(
|
||||
context: CanvasRenderingContext2D,
|
||||
layer: PreparedLayer,
|
||||
view: ViewState,
|
||||
color: string,
|
||||
everyM = 25,
|
||||
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. */
|
||||
uprightRad = 0,
|
||||
): void {
|
||||
const affine = affineOf(view);
|
||||
const margin = CULL_MARGIN;
|
||||
const step = everyM > 0 ? everyM : 25;
|
||||
// 이미 찍은 라벨과 겹치면 건너뛴다 — LAS 등고선은 **한 표고가 여러 가닥**으로 끊겨 있어
|
||||
// 가닥마다 숫자를 내면 화면이 숫자로 덮인다(2026-09-12 실화면). 도엽 계곡선은 원래
|
||||
// 드물어 이 규칙에 걸리지 않으므로 B04 지도의 표기는 그대로다.
|
||||
const drawn: Array<{ x: number; y: number; half: number }> = [];
|
||||
for (const feature of layer.features) {
|
||||
if (feature.labelText === null) continue;
|
||||
// 표고를 못 읽은 라벨(값 없음)은 솎지 않고 그대로 낸다.
|
||||
if (feature.labelValue !== null && feature.labelValue % step !== 0) continue;
|
||||
const x = feature.labelAnchorX * affine.ax + affine.bx;
|
||||
const y = feature.labelAnchorY * affine.ay + affine.by;
|
||||
if (x < -margin || x > view.width + margin) continue;
|
||||
if (y < -margin || y > view.height + margin) continue;
|
||||
const half = context.measureText(feature.labelText).width / 2 + LABEL_GAP_PX;
|
||||
if (
|
||||
drawn.some(
|
||||
(item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < LABEL_ROW_PX,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
drawn.push({ x, y, half });
|
||||
context.save();
|
||||
if (uprightRad) {
|
||||
// 글자 **자리는 그대로** 두고 글자만 되돌린다 — 180°에서 숫자가 뒤집혀 안 읽힌다.
|
||||
context.translate(x, y);
|
||||
context.rotate(uprightRad);
|
||||
context.translate(-x, -y);
|
||||
}
|
||||
context.lineWidth = 3;
|
||||
context.strokeStyle = haloColor();
|
||||
context.strokeText(feature.labelText, x, y);
|
||||
context.fillStyle = color;
|
||||
context.fillText(feature.labelText, x, y);
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/* =============================================================================
|
||||
* B04_PreProcess_UI_MapRender_Prepare.ts
|
||||
* 지도 레이어 **사전 투영** — GeoJSON·사업지 좌표 폴리라인을 정규화 좌표로 펴고,
|
||||
* 줌 무손실 LOD 가중치(Douglas-Peucker)와 등고 라벨 앵커를 미리 잡아 둔다.
|
||||
*
|
||||
* `B04_PreProcess_UI_MapRender.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12).
|
||||
* 본문 로직과 수치는 그대로다. 그리기는 그쪽, 준비는 이쪽 — 한 방향으로만 기대어
|
||||
* 순환 참조가 생기지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
|
||||
import type {
|
||||
GeoJsonCollection,
|
||||
GeoJsonGeometry,
|
||||
Normalizer,
|
||||
PreparedFeature,
|
||||
PreparedLayer,
|
||||
PreparedPart,
|
||||
} from "./B04_PreProcess_UI_MapRender";
|
||||
|
||||
function isPoint(value: unknown): value is [number, number] {
|
||||
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
|
||||
}
|
||||
|
||||
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
|
||||
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
|
||||
if (!Array.isArray(ring) || ring.length === 0) return null;
|
||||
const coords = new Float64Array(ring.length * 2);
|
||||
let count = 0;
|
||||
for (const point of ring) {
|
||||
if (!isPoint(point)) continue;
|
||||
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
|
||||
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
|
||||
count += 1;
|
||||
}
|
||||
if (count === 0) return null;
|
||||
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
|
||||
}
|
||||
|
||||
function collectParts(
|
||||
geometry: GeoJsonGeometry,
|
||||
normalizer: Normalizer,
|
||||
parts: PreparedPart[],
|
||||
): "line" | "point" {
|
||||
const coordinates = geometry.coordinates;
|
||||
if (!Array.isArray(coordinates)) return "line";
|
||||
const push = (ring: unknown, closed: boolean): void => {
|
||||
const projected = projectRing(ring, normalizer);
|
||||
if (projected) parts.push({ coords: projected, closed, weights: null });
|
||||
};
|
||||
switch (geometry.type) {
|
||||
case "Point":
|
||||
push([coordinates], false);
|
||||
return "point";
|
||||
case "MultiPoint":
|
||||
push(coordinates, false);
|
||||
return "point";
|
||||
case "LineString":
|
||||
push(coordinates, false);
|
||||
return "line";
|
||||
case "MultiLineString":
|
||||
for (const line of coordinates) push(line, false);
|
||||
return "line";
|
||||
case "Polygon":
|
||||
for (const ring of coordinates) push(ring, true);
|
||||
return "line";
|
||||
case "MultiPolygon":
|
||||
for (const polygon of coordinates) {
|
||||
if (!Array.isArray(polygon)) continue;
|
||||
for (const ring of polygon) push(ring, true);
|
||||
}
|
||||
return "line";
|
||||
default:
|
||||
return "line";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
|
||||
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
|
||||
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
|
||||
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
|
||||
*/
|
||||
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
|
||||
const n = coords.length / 2;
|
||||
const weights = new Float64Array(n);
|
||||
weights[0] = Infinity;
|
||||
weights[n - 1] = Infinity;
|
||||
if (n <= 2) return weights;
|
||||
const stack: number[] = [0, n - 1];
|
||||
const caps: number[] = [Infinity];
|
||||
while (stack.length) {
|
||||
const last = stack.pop()!;
|
||||
const first = stack.pop()!;
|
||||
const cap = caps.pop()!;
|
||||
if (last - first < 2) continue;
|
||||
const ax = coords[first * 2];
|
||||
const ay = coords[first * 2 + 1] / aspect;
|
||||
const bx = coords[last * 2];
|
||||
const by = coords[last * 2 + 1] / aspect;
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
let maxDist = -1;
|
||||
let maxIndex = -1;
|
||||
for (let i = first + 1; i < last; i += 1) {
|
||||
const px = coords[i * 2] - ax;
|
||||
const py = coords[i * 2 + 1] / aspect - ay;
|
||||
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
|
||||
if (dist > maxDist) {
|
||||
maxDist = dist;
|
||||
maxIndex = i;
|
||||
}
|
||||
}
|
||||
const weight = Math.min(maxDist, cap);
|
||||
weights[maxIndex] = weight;
|
||||
stack.push(first, maxIndex, maxIndex, last);
|
||||
caps.push(weight, weight);
|
||||
}
|
||||
return weights;
|
||||
}
|
||||
|
||||
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
|
||||
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
|
||||
const coords = geometry.coordinates;
|
||||
if (!Array.isArray(coords)) return null;
|
||||
const line =
|
||||
geometry.type === "LineString"
|
||||
? coords
|
||||
: geometry.type === "MultiLineString"
|
||||
? coords[0]
|
||||
: null;
|
||||
if (!Array.isArray(line) || line.length === 0) return null;
|
||||
const mid = line[Math.floor(line.length / 2)];
|
||||
if (!isPoint(mid)) return null;
|
||||
return [
|
||||
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
|
||||
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoJSON 컬렉션 1개를 사전 투영한다.
|
||||
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
|
||||
*/
|
||||
export function prepareLayer(
|
||||
collection: GeoJsonCollection | undefined,
|
||||
normalizer: Normalizer,
|
||||
labelKeys?: string[],
|
||||
): PreparedLayer {
|
||||
const features: PreparedFeature[] = [];
|
||||
for (const feature of collection?.features ?? []) {
|
||||
if (!feature.geometry) continue;
|
||||
const parts: PreparedPart[] = [];
|
||||
const kind = collectParts(feature.geometry, normalizer, parts);
|
||||
if (parts.length === 0) continue;
|
||||
if (kind === "line") {
|
||||
for (const part of parts) {
|
||||
if (part.coords.length < 6) continue;
|
||||
part.weights = computeDpWeights(part.coords, normalizer.aspect);
|
||||
}
|
||||
}
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const part of parts) {
|
||||
const coords = part.coords;
|
||||
for (let i = 0; i < coords.length; i += 2) {
|
||||
const x = coords[i];
|
||||
const y = coords[i + 1];
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
let labelText: string | null = null;
|
||||
let labelValue: number | null = null;
|
||||
let labelAnchorX = 0;
|
||||
let labelAnchorY = 0;
|
||||
if (labelKeys && labelKeys.length > 0) {
|
||||
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
|
||||
const elevation = typeof raw === "number" ? raw : Number(raw);
|
||||
// **모든 등고선**에 앵커를 잡아 둔다. 어느 줄을 실제로 낼지는 그릴 때 고른다 —
|
||||
// 화면마다 솎는 눈금이 다르기 때문이다(B04 지도는 계곡선만, 계획노선 편집 모달은
|
||||
// 확대에 따라 더 촘촘히). 준비 단계에서 걸러 버리면 확대해도 되살릴 수가 없다.
|
||||
if (Number.isFinite(elevation)) {
|
||||
const anchor = labelAnchorOf(feature.geometry, normalizer);
|
||||
if (anchor) {
|
||||
labelText = String(elevation);
|
||||
labelValue = elevation;
|
||||
labelAnchorX = anchor[0];
|
||||
labelAnchorY = anchor[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
features.push({
|
||||
kind,
|
||||
parts,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
labelAnchorX,
|
||||
labelAnchorY,
|
||||
labelText,
|
||||
labelValue,
|
||||
});
|
||||
}
|
||||
return { features };
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
|
||||
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
|
||||
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
|
||||
*/
|
||||
export function prepareMetricPolyline(
|
||||
points: ReadonlyArray<{ x: number; y: number }>,
|
||||
meta: VWorldMeta,
|
||||
): PreparedLayer {
|
||||
if (points.length < 2) return { features: [] };
|
||||
const widthMeters = meta.width_meters || 1;
|
||||
const heightMeters = meta.height_meters || 1;
|
||||
const coords = new Float64Array(points.length * 2);
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
points.forEach((point, index) => {
|
||||
const nx = (point.x - meta.x_min) / widthMeters;
|
||||
const ny = 1 - (point.y - meta.y_min) / heightMeters;
|
||||
coords[index * 2] = nx;
|
||||
coords[index * 2 + 1] = ny;
|
||||
if (nx < minX) minX = nx;
|
||||
if (nx > maxX) maxX = nx;
|
||||
if (ny < minY) minY = ny;
|
||||
if (ny > maxY) maxY = ny;
|
||||
});
|
||||
return {
|
||||
features: [
|
||||
{
|
||||
kind: "line",
|
||||
parts: [{ coords, closed: false, weights: null }],
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
labelAnchorX: 0,
|
||||
labelAnchorY: 0,
|
||||
labelText: null,
|
||||
labelValue: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 사업지 좌표계(m) 폴리라인 **여러 개**를 한 레이어로 사전 투영한다(LAS 등고선 등).
|
||||
*
|
||||
* `prepareMetricPolyline` 의 여러 줄 판이다. 줄마다 `label`(표고 m)을 주면 가운데 정점을
|
||||
* 앵커로 잡아 `drawPreparedLabels` 가 그대로 쓸 수 있다.
|
||||
*/
|
||||
export function prepareMetricPolylines(
|
||||
lines: ReadonlyArray<{ points: ReadonlyArray<readonly [number, number]>; label?: number }>,
|
||||
meta: VWorldMeta,
|
||||
): PreparedLayer {
|
||||
const widthMeters = meta.width_meters || 1;
|
||||
const heightMeters = meta.height_meters || 1;
|
||||
const features: PreparedFeature[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.points.length < 2) continue;
|
||||
const coords = new Float64Array(line.points.length * 2);
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
line.points.forEach((point, index) => {
|
||||
const nx = (point[0] - meta.x_min) / widthMeters;
|
||||
const ny = 1 - (point[1] - meta.y_min) / heightMeters;
|
||||
coords[index * 2] = nx;
|
||||
coords[index * 2 + 1] = ny;
|
||||
if (nx < minX) minX = nx;
|
||||
if (nx > maxX) maxX = nx;
|
||||
if (ny < minY) minY = ny;
|
||||
if (ny > maxY) maxY = ny;
|
||||
});
|
||||
const middle = Math.floor(line.points.length / 2) * 2;
|
||||
features.push({
|
||||
kind: "line",
|
||||
parts: [
|
||||
{ coords, closed: false, weights: computeDpWeights(coords, widthMeters / heightMeters) },
|
||||
],
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
labelAnchorX: coords[middle],
|
||||
labelAnchorY: coords[middle + 1],
|
||||
labelText: line.label === undefined ? null : String(line.label),
|
||||
labelValue: line.label ?? null,
|
||||
});
|
||||
}
|
||||
return { features };
|
||||
}
|
||||
@@ -33,8 +33,6 @@ import {
|
||||
createNormalizer,
|
||||
drawPreparedLabels,
|
||||
drawPreparedLayer,
|
||||
prepareLayer,
|
||||
prepareMetricPolyline,
|
||||
routeLineColor,
|
||||
ROUTE_LINE_WIDTH,
|
||||
type GeoJsonCollection,
|
||||
@@ -44,6 +42,7 @@ import {
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "./B04_PreProcess_UI_MapRender";
|
||||
import { prepareLayer, prepareMetricPolyline } from "./B04_PreProcess_UI_MapRender_Prepare";
|
||||
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
|
||||
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
|
||||
|
||||
|
||||
@@ -56,8 +56,12 @@ export interface RoutePlanResponse {
|
||||
nodes: RoutePlanNode[];
|
||||
/** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */
|
||||
curves: RoutePlanCurve[];
|
||||
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */
|
||||
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m) — **기본값·위반 표시 기준**. */
|
||||
min_radius_m: number;
|
||||
/** **못 넘는** 곡선반지름 하한(m). 0이면 제한 없음(작업임도). 기본값과 다른 값이다. */
|
||||
limit_radius_m?: number;
|
||||
/** **못 넘는** 곡선 길이 하한(m). 0이면 제한 없음 — 지금은 전부 0(법에 값이 없음). */
|
||||
limit_curve_length_m?: number;
|
||||
curve_count: number;
|
||||
violation_count: number;
|
||||
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
|
||||
@@ -128,6 +132,60 @@ export async function replanRoute(
|
||||
);
|
||||
}
|
||||
|
||||
/** 점 묶음의 **지반고**를 묻는다(계획서 0-9 ⑤·⑧).
|
||||
*
|
||||
* 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안
|
||||
* 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다. */
|
||||
export async function fetchRouteElevations(
|
||||
projectId: string,
|
||||
points: Array<[number, number]>,
|
||||
): Promise<Array<number | null>> {
|
||||
const payload = await requestJson<{ z: Array<number | null> }>(
|
||||
`/projects/${projectId}/route/elevations`,
|
||||
{ method: "POST", body: JSON.stringify({ points }) },
|
||||
60000,
|
||||
);
|
||||
return payload.z;
|
||||
}
|
||||
|
||||
/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */
|
||||
export interface CrossPreviewResponse {
|
||||
status: string;
|
||||
chainage_m: number;
|
||||
label: string | null;
|
||||
uphill_side: string | null;
|
||||
plan_radius_m: number | null;
|
||||
curve_widening_m: number | null;
|
||||
/** 원지반 횡단 샘플. */
|
||||
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
|
||||
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
|
||||
design: {
|
||||
design_line: Array<{ offset_m: number; elevation_m: number }>;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CrossPreviewRequest {
|
||||
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
||||
chainage_m: number;
|
||||
min_radius_m: number;
|
||||
station_interval_m: number;
|
||||
}
|
||||
|
||||
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
|
||||
export async function fetchCrossPreview(
|
||||
projectId: string,
|
||||
request: CrossPreviewRequest,
|
||||
): Promise<CrossPreviewResponse> {
|
||||
return requestJson<CrossPreviewResponse>(
|
||||
`/projects/${projectId}/route/cross-preview`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
120000,
|
||||
);
|
||||
}
|
||||
|
||||
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
|
||||
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
|
||||
return requestJson<RouteReplanResponse>(
|
||||
|
||||
@@ -31,6 +31,10 @@ export interface StructureOptionField {
|
||||
/** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다
|
||||
* (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */
|
||||
phase?: "b05" | "detail";
|
||||
/** **비워 두는 것이 뜻인 칸** — 비면 계산 쪽이 기준값으로 돌고 그 사실이 사유로 뜬다.
|
||||
* ⚠ 이 칸의 select 는 **빈 보기를 둔다** — 첫 보기를 슬쩍 고르면 기준값과 다른 값이
|
||||
* 조용히 저장된다(2026-09-13: `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). */
|
||||
empty_means?: string | null;
|
||||
}
|
||||
|
||||
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
|
||||
|
||||
@@ -151,6 +151,37 @@ def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal")
|
||||
return float(speeds[terrain])
|
||||
|
||||
|
||||
def plan_radius_limit_m(
|
||||
grade_class: str,
|
||||
design_speed_kph: int | None = None,
|
||||
terrain_type: str = "normal",
|
||||
) -> float:
|
||||
"""계획노선 편집 화면이 **못 넘게 막을** 평면 곡선반지름 하한(m). 0이면 제한 없음.
|
||||
|
||||
위 `legal_plan_radius_min_m` 은 **기본값·위반 표시 기준**이고 이것은 **제한**이다
|
||||
(2026-09-12 사용자 확정: 「아예 못 넘게 막음」). 임도 종류별 칸이 비어 있으면(None)
|
||||
법정 표를 그대로 하한으로 쓰고, 값이 적혀 있으면 그 값을 쓴다 — 작업임도는 별표2에
|
||||
곡선반지름 규정이 없어 0(제한 없음)으로 열려 있다.
|
||||
"""
|
||||
table = FOREST_ROAD_PROFILE_CRITERIA["plan_radius_limit_by_grade_m"]
|
||||
override = table.get(grade_class)
|
||||
if override is not None:
|
||||
return float(override)
|
||||
return legal_plan_radius_min_m(
|
||||
resolve_design_speed(grade_class, design_speed_kph), terrain_type
|
||||
)
|
||||
|
||||
|
||||
def plan_curve_length_limit_m(grade_class: str) -> float:
|
||||
"""평면 **곡선 길이(L)** 하한(m). 0이면 제한 없음.
|
||||
|
||||
법령·교본에 값이 없어 지금은 임도 종류 전부 0이다 — 자리만 열어 둔 칸이라
|
||||
실무값이 정해지면 `config_system_design` 의 표만 고치면 된다(2026-09-12 사용자 확정).
|
||||
"""
|
||||
table = FOREST_ROAD_PROFILE_CRITERIA["plan_curve_length_limit_by_grade_m"]
|
||||
return float(table.get(grade_class) or 0.0)
|
||||
|
||||
|
||||
def _pick(*candidates: Any) -> Any:
|
||||
"""요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다."""
|
||||
for value in candidates:
|
||||
|
||||
@@ -37,7 +37,11 @@ from common_util.common_util_drainage_pipes import (
|
||||
route_signature,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_route_geometry import RouteVertex
|
||||
from common_util.common_util_route_geometry import (
|
||||
RouteVertex,
|
||||
planned_route_initial_path,
|
||||
planned_route_working_path,
|
||||
)
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
from config.config_system import (
|
||||
DRAINAGE_CACHE_DIRNAME,
|
||||
@@ -66,6 +70,34 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[
|
||||
return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords]
|
||||
|
||||
|
||||
def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""설계가 쓰는 계획노선의 **곡선표** — 없으면 빈 목록.
|
||||
|
||||
곡선표는 폴리라인 파일 옆에 같은 이름으로 선다(`…_curves.json`). 정점 목록만으로는
|
||||
어디부터 어디까지가 한 곡선이고 반지름이 얼마인지 알 수 없어, 곡선부 확폭을 측점마다
|
||||
반경을 다시 재는 방식으로 매기면 같은 곡선 안에서도 값이 갈린다(2026-09-12 실측).
|
||||
|
||||
수정본(`planned_route.csv`)이 있으면 **그 곡선표만** 쓴다 — 노선을 고쳤는데 초기본
|
||||
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고,
|
||||
받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다.
|
||||
"""
|
||||
route_path = planned_route_working_path(project_root)
|
||||
if not route_path.is_file():
|
||||
route_path = planned_route_initial_path(project_root)
|
||||
target = route_path.with_name(f"{route_path.stem}_curves.json")
|
||||
if not target.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
|
||||
return []
|
||||
curves = data.get("curves") if isinstance(data, dict) else None
|
||||
return (
|
||||
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
|
||||
)
|
||||
|
||||
|
||||
def cross_filename(chainage_m: float) -> str:
|
||||
"""측점 chainage에 대응하는 횡단면 파일명(단일 규칙)."""
|
||||
return f"cross_{int(round(float(chainage_m))):05d}m.json"
|
||||
@@ -361,6 +393,9 @@ def run_section_generation(
|
||||
replace(options or SectionGenerationOptions(), extra_stations=extras),
|
||||
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
|
||||
crs=crs,
|
||||
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
|
||||
# 종전의 측점별 실측 반경으로 물러선다.
|
||||
design_curves=_load_design_curves(project_root),
|
||||
)
|
||||
|
||||
stage_root = project_root / _STAGE_SUBDIR
|
||||
@@ -473,6 +508,8 @@ def generate_irregular_sections(
|
||||
merged_options,
|
||||
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
|
||||
crs=crs,
|
||||
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
|
||||
design_curves=_load_design_curves(project_root),
|
||||
)
|
||||
irregular_stations = [
|
||||
station
|
||||
|
||||
@@ -213,6 +213,101 @@ def _curve_widenings(
|
||||
return widenings, sides
|
||||
|
||||
|
||||
#: 곡선표의 시·종점이 노선 폴리라인에서 이만큼 떨어져 있으면 그 노선의 곡선이 아니라고 본다(m).
|
||||
#: 노선을 잘라 쓰면(지표면 밖 트림) 곡선표에 남은 옛 곡선이 노선 밖에 뜬다.
|
||||
DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0
|
||||
|
||||
|
||||
def _project_chainage(
|
||||
points: np.ndarray, route_chainage: np.ndarray, xy: tuple[float, float]
|
||||
) -> tuple[float, float]:
|
||||
"""점을 노선 폴리라인 위로 내려 (떨어진 거리 m, 누가거리 m)."""
|
||||
starts = points[:-1, :2]
|
||||
vectors = points[1:, :2] - starts
|
||||
lengths2 = np.einsum("ij,ij->i", vectors, vectors)
|
||||
safe = np.where(lengths2 > 1e-12, lengths2, 1.0)
|
||||
target = np.asarray(xy, dtype=np.float64)
|
||||
ratios = np.clip(np.einsum("ij,ij->i", target - starts, vectors) / safe, 0.0, 1.0)
|
||||
feet = starts + vectors * ratios[:, None]
|
||||
distances = np.hypot(feet[:, 0] - target[0], feet[:, 1] - target[1])
|
||||
index = int(np.argmin(distances))
|
||||
span = route_chainage[index + 1] - route_chainage[index]
|
||||
return float(distances[index]), float(route_chainage[index] + span * ratios[index])
|
||||
|
||||
|
||||
def _design_curve_spans(
|
||||
points: np.ndarray,
|
||||
route_chainage: np.ndarray,
|
||||
curves: list[dict[str, Any]],
|
||||
) -> list[tuple[float, float, float, str]]:
|
||||
"""설계 곡선표 → [(시점 누가거리, 종점 누가거리, 반지름 m, 곡선 **바깥쪽**)].
|
||||
|
||||
바깥쪽은 회전 방향으로 가른다 — 시점→교점→종점의 외적 z가 양수면 좌회전이라 안쪽이
|
||||
좌측이고 바깥은 우측이다(`_plan_radii` 와 같은 규약). 노선에서 멀리 떨어진 곡선과
|
||||
방향을 못 재는 곡선은 버린다.
|
||||
"""
|
||||
spans: list[tuple[float, float, float, str]] = []
|
||||
for curve in curves:
|
||||
start, apex, end = curve.get("start"), curve.get("apex"), curve.get("end")
|
||||
radius = curve.get("radius_m")
|
||||
if not (start and apex and end) or not isinstance(radius, (int, float)):
|
||||
continue
|
||||
start_gap, start_chainage = _project_chainage(points, route_chainage, tuple(start[:2]))
|
||||
end_gap, end_chainage = _project_chainage(points, route_chainage, tuple(end[:2]))
|
||||
if max(start_gap, end_gap) > DESIGN_CURVE_MATCH_TOLERANCE_M:
|
||||
continue
|
||||
cross = (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (
|
||||
end[0] - apex[0]
|
||||
)
|
||||
if abs(cross) < 1e-12:
|
||||
continue
|
||||
spans.append(
|
||||
(
|
||||
min(start_chainage, end_chainage),
|
||||
max(start_chainage, end_chainage),
|
||||
float(radius),
|
||||
"right" if cross > 0 else "left",
|
||||
)
|
||||
)
|
||||
return sorted(spans)
|
||||
|
||||
|
||||
def _design_curve_widenings(
|
||||
station_chainage: np.ndarray,
|
||||
spans: list[tuple[float, float, float, str]],
|
||||
) -> tuple[list[float | None], list[str | None], list[float]]:
|
||||
"""설계 곡선표로 측점별 (평면 곡선반경, 바깥쪽, 확폭량)을 낸다.
|
||||
|
||||
곡선 **안**은 그 곡선의 설계 반경이 그대로 반경이고 확폭도 표값 한 값이다. 곡선 앞뒤
|
||||
`CURVE_WIDENING_TAPER_M` 구간은 0 으로 잇고(실무 관행 직선 테이퍼), 그 밖은 확폭이
|
||||
없다. 곡선이 겹치면 **확폭이 큰 쪽**을 따르고, 반경은 작은 쪽(급한 쪽)을 남긴다.
|
||||
"""
|
||||
count = len(station_chainage)
|
||||
radii: list[float | None] = [None] * count
|
||||
sides: list[str | None] = [None] * count
|
||||
widenings: list[float] = [0.0] * count
|
||||
for start, end, radius, side in spans:
|
||||
table = curve_widening_m(radius)
|
||||
for index, value in enumerate(station_chainage):
|
||||
chainage = float(value)
|
||||
inside = start - 1e-9 <= chainage <= end + 1e-9
|
||||
if inside and (radii[index] is None or radius < radii[index]):
|
||||
radii[index] = round(radius, 3)
|
||||
if table <= 0.0:
|
||||
continue
|
||||
if inside:
|
||||
amount = table
|
||||
else:
|
||||
distance = start - chainage if chainage < start else chainage - end
|
||||
if distance > CURVE_WIDENING_TAPER_M:
|
||||
continue
|
||||
amount = round(table * (1.0 - distance / CURVE_WIDENING_TAPER_M), 4)
|
||||
if amount > widenings[index]:
|
||||
widenings[index] = amount
|
||||
sides[index] = side
|
||||
return radii, sides, widenings
|
||||
|
||||
|
||||
def generate_sections(
|
||||
polyline: np.ndarray | list[list[float]],
|
||||
sampler: SurfaceElevationSampler,
|
||||
@@ -220,6 +315,7 @@ def generate_sections(
|
||||
*,
|
||||
source_snapshot: dict[str, Any] | None = None,
|
||||
crs: str | None = None,
|
||||
design_curves: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
|
||||
options = options or SectionGenerationOptions()
|
||||
@@ -267,11 +363,21 @@ def generate_sections(
|
||||
# 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반
|
||||
# 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세
|
||||
# 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다.
|
||||
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
|
||||
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
|
||||
plan_widenings, plan_outer_sides = _curve_widenings(
|
||||
station_chainage, plan_radii, plan_outer_sides
|
||||
)
|
||||
# **설계 곡선표가 있으면 그것이 정본**이다(2026-09-12 사용자 지시). 측점마다 반경을
|
||||
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
|
||||
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
|
||||
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
|
||||
spans = _design_curve_spans(points, route_chainage, design_curves or [])
|
||||
if spans:
|
||||
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
|
||||
station_chainage, spans
|
||||
)
|
||||
else:
|
||||
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
|
||||
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
|
||||
plan_widenings, plan_outer_sides = _curve_widenings(
|
||||
station_chainage, plan_radii, plan_outer_sides
|
||||
)
|
||||
|
||||
offsets = np.arange(
|
||||
-options.cross_half_width_m,
|
||||
|
||||
@@ -133,7 +133,19 @@ def _ensure_expected_route(project_root: Path) -> str:
|
||||
|
||||
|
||||
async def _min_plan_radius_m(project_id: UUID) -> float:
|
||||
"""이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다.
|
||||
"""기본 반지름만 필요한 자리 — 하한까지 필요하면 `_plan_criteria` 를 쓸 것."""
|
||||
criteria = await _plan_criteria(project_id)
|
||||
return criteria[0]
|
||||
|
||||
|
||||
async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]:
|
||||
"""이 프로젝트의 **기본 반지름 · 반지름 하한 · 곡선 길이 하한**(m) 세 값.
|
||||
|
||||
기본 반지름은 곡선을 만들 때 쓰는 값이고, 하한 둘은 **화면이 못 넘게 막는** 값이다
|
||||
(2026-09-12 사용자 확정). 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어 곡선이
|
||||
아예 안 그려지므로 반드시 갈라 둔다.
|
||||
|
||||
기본 반지름은 임도 종류·설계속도·지형으로 고른다.
|
||||
|
||||
값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 Ⅰ.2.다)이고 산식은 이미
|
||||
`B05_Profile_Engine_Grade.legal_plan_radius_min_m` 에 있다 — 여기서 다시 짜지 않는다.
|
||||
@@ -145,7 +157,12 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
|
||||
"""
|
||||
import aiomysql
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed
|
||||
from B05_Profile.B05_Profile_Engine_Grade import (
|
||||
legal_plan_radius_min_m,
|
||||
plan_curve_length_limit_m,
|
||||
plan_radius_limit_m,
|
||||
resolve_design_speed,
|
||||
)
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
|
||||
grade_class, design_speed, terrain = "work", None, "special"
|
||||
@@ -172,7 +189,11 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
|
||||
terrain = str(params["terrain_type"])
|
||||
except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다
|
||||
logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id)
|
||||
return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain)
|
||||
return (
|
||||
legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain),
|
||||
plan_radius_limit_m(grade_class, design_speed, terrain),
|
||||
plan_curve_length_limit_m(grade_class),
|
||||
)
|
||||
|
||||
|
||||
def _nodes_path(path: Path) -> Path:
|
||||
@@ -404,7 +425,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
if not expected:
|
||||
expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root))
|
||||
|
||||
radius_m = await _min_plan_radius_m(project_id)
|
||||
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
|
||||
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
||||
working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root))
|
||||
initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root))
|
||||
@@ -445,6 +466,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
# (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다.
|
||||
"curves": saved_curves or [curve.as_dict() for curve in outline.curves],
|
||||
"min_radius_m": round(radius_m, 2),
|
||||
# **못 넘는 하한** — 기본값(`min_radius_m`)과 다른 값이다. 0이면 제한 없음
|
||||
# (작업임도는 별표2에 곡선반지름 규정이 없어 0으로 열려 있다, 2026-09-12 확정).
|
||||
"limit_radius_m": round(radius_limit_m, 2),
|
||||
"limit_curve_length_m": round(arc_limit_m, 2),
|
||||
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
|
||||
"violation_count": outline.violation_count,
|
||||
"edited": bool(working),
|
||||
@@ -468,7 +493,7 @@ async def replan_route(
|
||||
|
||||
# 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다.
|
||||
await asyncio.to_thread(_ensure_expected_route, project_root)
|
||||
radius_m = await _min_plan_radius_m(project_id)
|
||||
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
|
||||
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
||||
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
|
||||
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
|
||||
@@ -527,7 +552,7 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
project_root, stored_path = paths
|
||||
await asyncio.to_thread(_ensure_expected_route, project_root)
|
||||
radius_m = await _min_plan_radius_m(project_id)
|
||||
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
|
||||
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
||||
working_path = planned_route_working_path(project_root)
|
||||
if working_path.is_file():
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로.
|
||||
|
||||
편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 두 점을 찍어
|
||||
**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)와 한 측점의 **횡단도 미리보기**(⑧)는 지반고가
|
||||
있어야 한다. 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지
|
||||
않는다 — 노선을 갈아 끼우지도, 정본을 건드리지도 않는다.
|
||||
|
||||
표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다.
|
||||
두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
|
||||
|
||||
POST /api/projects/{id}/route/elevations → 점 묶음의 지반고
|
||||
POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Terrain"])
|
||||
|
||||
_MODELS_SUBDIR = Path("B04_PreProcess") / "models"
|
||||
#: 한 번에 물을 수 있는 점 수. 구간 재기는 수십 점, 횡단 한 장은 수백 점이면 넉넉하다 —
|
||||
#: 상한을 두어 실수로 노선 전체를 밀어 넣는 일을 막는다.
|
||||
MAX_POINTS = 4000
|
||||
|
||||
|
||||
class ElevationRequest(BaseModel):
|
||||
"""사업지 좌표계(m) 점 묶음 [[x, y], …]."""
|
||||
|
||||
points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS)
|
||||
|
||||
|
||||
def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]):
|
||||
"""확정 지표면에서 표고를 읽는다. 모델을 못 열면 None."""
|
||||
try:
|
||||
sampler = build_surface_sampler(
|
||||
project_root / _MODELS_SUBDIR,
|
||||
str(params["source_filter"]),
|
||||
str(params["method"]),
|
||||
bool(params["smooth"]),
|
||||
)
|
||||
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
|
||||
logger.warning("계획노선 편집: 지표면을 열지 못했습니다 — %s", exc)
|
||||
return None
|
||||
z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64))
|
||||
return z, valid
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/elevations", response_model=None)
|
||||
async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> dict | JSONResponse:
|
||||
"""점 묶음의 지반고(m)와 유효 여부를 돌려준다.
|
||||
|
||||
지표면 밖이거나 자료가 없는 자리는 `valid=false` 로 나가고 표고는 `null` 이다 —
|
||||
**임의 표고로 메우지 않는다**(sampler 규칙 그대로). 화면은 그 자리를 「모름」으로 낸다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored))
|
||||
sampled = await asyncio.to_thread(_sample, project_root, params, request.points)
|
||||
if sampled is None:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "확정된 지표면이 없어 지반고를 읽을 수 없습니다.",
|
||||
},
|
||||
)
|
||||
z, valid = sampled
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)],
|
||||
"valid": [bool(ok) for ok in valid],
|
||||
}
|
||||
|
||||
|
||||
class PreviewVertex(BaseModel):
|
||||
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
curve: bool = True
|
||||
radius_m: float | None = None
|
||||
|
||||
|
||||
class CrossPreviewRequest(BaseModel):
|
||||
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
|
||||
|
||||
vertices: list[PreviewVertex] = Field(..., min_length=2)
|
||||
chainage_m: float = Field(..., ge=0)
|
||||
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
|
||||
min_radius_m: float = Field(12.0, gt=0)
|
||||
station_interval_m: float | None = None
|
||||
|
||||
|
||||
def _cross_preview(
|
||||
project_root: Path,
|
||||
params: dict,
|
||||
request: CrossPreviewRequest,
|
||||
) -> dict | None:
|
||||
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
|
||||
|
||||
**B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에
|
||||
존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design`
|
||||
이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
|
||||
|
||||
⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지
|
||||
않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면
|
||||
기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다.
|
||||
"""
|
||||
try:
|
||||
sampler = build_surface_sampler(
|
||||
project_root / _MODELS_SUBDIR,
|
||||
str(params["source_filter"]),
|
||||
str(params["method"]),
|
||||
bool(params["smooth"]),
|
||||
)
|
||||
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
|
||||
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
|
||||
return None
|
||||
|
||||
built = build_planned_polyline(
|
||||
[(vertex.x, vertex.y) for vertex in request.vertices],
|
||||
min_radius_m=request.min_radius_m,
|
||||
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
|
||||
simplify=False,
|
||||
curve_flags=[vertex.curve for vertex in request.vertices],
|
||||
radii=[vertex.radius_m for vertex in request.vertices],
|
||||
)
|
||||
interval = request.station_interval_m
|
||||
options = (
|
||||
SectionGenerationOptions(station_interval_m=float(interval))
|
||||
if interval and interval > 0
|
||||
else SectionGenerationOptions()
|
||||
)
|
||||
result = generate_sections(built.vertices, sampler, options)
|
||||
sections = result["cross_sections"]
|
||||
if not sections:
|
||||
return None
|
||||
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
|
||||
|
||||
design = None
|
||||
center_z = section.get("center_z")
|
||||
if center_z is not None:
|
||||
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
|
||||
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
|
||||
design = compute_cross_design(
|
||||
section["samples"],
|
||||
float(center_z),
|
||||
ground_type="soil",
|
||||
section_mode=section_mode,
|
||||
**curve_widening_args(section),
|
||||
)
|
||||
return {
|
||||
"chainage_m": round(float(section["chainage_m"]), 3),
|
||||
"label": section.get("label"),
|
||||
"uphill_side": section.get("uphill_side"),
|
||||
"plan_radius_m": section.get("plan_radius_m"),
|
||||
"curve_widening_m": section.get("curve_widening_m"),
|
||||
"samples": section["samples"],
|
||||
"design": design,
|
||||
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
|
||||
if result.get("longitudinal", {}).get("total_length_m") is not None
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/cross-preview", response_model=None)
|
||||
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
|
||||
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
|
||||
|
||||
정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored))
|
||||
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
|
||||
if preview is None:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
|
||||
},
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), **preview}
|
||||
@@ -924,6 +924,17 @@
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
|
||||
},
|
||||
{
|
||||
"key": "blinding_concrete",
|
||||
"label": "버림 콘크리트",
|
||||
"input": "select",
|
||||
"choices": ["넣음", "안 넣음"],
|
||||
"default": "넣음",
|
||||
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1086,6 +1097,17 @@
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
|
||||
},
|
||||
{
|
||||
"key": "blinding_concrete",
|
||||
"label": "버림 콘크리트",
|
||||
"input": "select",
|
||||
"choices": ["넣음", "안 넣음"],
|
||||
"default": "넣음",
|
||||
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1441,6 +1463,17 @@
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
|
||||
},
|
||||
{
|
||||
"key": "blinding_concrete",
|
||||
"label": "버림 콘크리트",
|
||||
"input": "select",
|
||||
"choices": ["넣음", "안 넣음"],
|
||||
"default": "넣음",
|
||||
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1991,6 +2024,17 @@
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
|
||||
},
|
||||
{
|
||||
"key": "blinding_concrete",
|
||||
"label": "버림 콘크리트",
|
||||
"input": "select",
|
||||
"choices": ["넣음", "안 넣음"],
|
||||
"default": "넣음",
|
||||
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -15,13 +15,15 @@ import {
|
||||
computeMapRect,
|
||||
MAP_STATION_INTERVAL_M,
|
||||
createNormalizer,
|
||||
prepareLayer,
|
||||
prepareMetricPolyline,
|
||||
type MapRect,
|
||||
type Normalizer,
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import {
|
||||
prepareLayer,
|
||||
prepareMetricPolyline,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import { type RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
|
||||
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
|
||||
|
||||
@@ -281,9 +281,20 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
|
||||
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
|
||||
onEditPlannedRoute: () =>
|
||||
void openRouteEditModal(activeProjectId, () => {
|
||||
navigateTo(ROUTES.B05_PROFILE);
|
||||
}),
|
||||
void openRouteEditModal(
|
||||
activeProjectId,
|
||||
() => {
|
||||
navigateTo(ROUTES.B05_PROFILE);
|
||||
},
|
||||
{
|
||||
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
|
||||
stationIntervalM: panel.values().stationInterval ?? undefined,
|
||||
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
|
||||
surfaceModelId: confirmedSurface?.model_id ?? null,
|
||||
contourIntervalM: latest?.surface_params.contour_interval_m,
|
||||
smooth: latest?.surface_params.smooth,
|
||||
},
|
||||
),
|
||||
onTempSave: () => void tempSaveAction(actionContext),
|
||||
onGoCross: () => {
|
||||
// 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "./B05_Profile_Api_Fetch";
|
||||
import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft";
|
||||
import { stateKey } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
invalidateSectionDetail,
|
||||
saveCachedCrossPatches,
|
||||
@@ -170,10 +171,16 @@ export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
|
||||
);
|
||||
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
|
||||
// ⚠ 이동 키까지 넘긴다(2026-09-12 사용자: 어느 페이지에서 저장해도 결과가 같아야
|
||||
// 한다). 종전에는 구간값 키만 넘겨, 옛 세션에 남은 관 이동 예약이 B05 [임시저장]에서는
|
||||
// 조용히 빠졌다. 관 목록 자체는 이제 두 페이지가 같은 스냅샷(`pipes`)을 쓴다.
|
||||
if (latest?.route?.id != null) {
|
||||
await flushCulvertOptions(projectId, `b06:culvertopt:${projectId}:${latest.route.id}`).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const routeId = latest.route.id;
|
||||
await flushCulvertOptions(
|
||||
projectId,
|
||||
stateKey("culvertopt", projectId, routeId),
|
||||
stateKey("culvertmove", projectId, routeId),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
|
||||
// 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가
|
||||
|
||||
@@ -16,36 +16,47 @@
|
||||
import {
|
||||
computeMapRect,
|
||||
computeRouteView,
|
||||
drawPreparedLayer,
|
||||
createNormalizer,
|
||||
hitPreparedLayer,
|
||||
metricToScreen,
|
||||
prepareLayer,
|
||||
pickLevelStep,
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
import type { RoutePlanCurve } from "./B05_Profile_Api_Replan";
|
||||
import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import { fetchRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
import {
|
||||
buildEditedPolyline,
|
||||
dragHandleTo as curveDragTo,
|
||||
type EditedCurve,
|
||||
type EditedNode,
|
||||
} from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
import {
|
||||
bindRouteEditNavigation,
|
||||
contourBandRect,
|
||||
handleAtScreen,
|
||||
nodeAtScreen,
|
||||
segmentAtScreen,
|
||||
stationAtScreen,
|
||||
} from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import {
|
||||
centerDirectionOf,
|
||||
createCurveLabel,
|
||||
deflectionRad,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
|
||||
import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
|
||||
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
||||
import {
|
||||
applyArcLocks,
|
||||
applyCurveLimits,
|
||||
curveShortfalls,
|
||||
radiusFloorM,
|
||||
curveSummary,
|
||||
flattenServerPlan,
|
||||
shortfallCrossed,
|
||||
type CurveLock,
|
||||
} from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
@@ -58,63 +69,38 @@ import "./B05_Profile_UI_Style_RouteEdit.css";
|
||||
|
||||
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
|
||||
const NODE_HIT_PX = 9;
|
||||
/** 노드 반지름(px). */
|
||||
const NODE_R = 4;
|
||||
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
|
||||
const SEGMENT_HIT_PX = 12;
|
||||
/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07).
|
||||
*
|
||||
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
|
||||
* 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을
|
||||
* 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */
|
||||
const CONTOUR_BAND_M = 300;
|
||||
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다.
|
||||
* 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */
|
||||
const CURVE_HANDLE_PX = 5;
|
||||
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
|
||||
const CONTOUR_HIT_PX = 6;
|
||||
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
|
||||
const STATION_HIT_PX = 11;
|
||||
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
|
||||
* (2026-09-12 사용자 지시 ⑦ 「좀더 촘촘히」). 편집 창은 노선 둘레 300m 띠만 보이므로
|
||||
* 같은 예산으로도 화면에 드는 줄이 적어 눈금이 필요 이상으로 성겼다. */
|
||||
const CONTOUR_LEVEL_BUDGET = 1200;
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
|
||||
export interface RouteEditOptions {
|
||||
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
|
||||
stationIntervalM?: number;
|
||||
/** 확정 지표면 모델 id — 있으면 바탕 등고선을 **LAS 것**으로 쓴다(계획서 0-9 ⑥). */
|
||||
surfaceModelId?: number | null;
|
||||
/** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */
|
||||
contourIntervalM?: number;
|
||||
smooth?: boolean;
|
||||
}
|
||||
|
||||
export async function openRouteEditModal(
|
||||
projectId: string,
|
||||
onApplied: () => void | Promise<void>,
|
||||
options: RouteEditOptions = {},
|
||||
): Promise<void> {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "b05-routeedit";
|
||||
overlay.innerHTML = `
|
||||
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
|
||||
<div class="b05-routeedit__head">
|
||||
<strong>계획노선 편집</strong>
|
||||
<span class="b05-routeedit__hint">
|
||||
노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 ·
|
||||
노드 오른쪽 클릭 = 삭제 · 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div>
|
||||
<div class="b05-routeedit__foot">
|
||||
<span class="b05-routeedit__status">노선을 읽는 중…</span>
|
||||
<span class="b05-routeedit__legend">
|
||||
<i class="is-expected"></i> 예상노선(원본)
|
||||
<i class="is-planned"></i> 계획노선
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="undo" title="되돌리기 (Ctrl+Z)"
|
||||
disabled>↶ 되돌리기</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="redo" title="다시하기 (Ctrl+Y)"
|
||||
disabled>↷ 다시하기</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="history-reset"
|
||||
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="reset">예상노선으로</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="cancel">취소</button>
|
||||
<button type="button" class="b05-routeedit__btn is-primary" data-act="apply">확인</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__busy" hidden><span></span></div>
|
||||
</div>`;
|
||||
document.body.append(overlay);
|
||||
|
||||
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
|
||||
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
|
||||
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
|
||||
const stationIntervalM = options.stationIntervalM ?? 20;
|
||||
const chrome = createRouteEditChrome();
|
||||
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
let expected: Vertex[] = [];
|
||||
@@ -123,14 +109,13 @@ export async function openRouteEditModal(
|
||||
/** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */
|
||||
let planned: Vertex[] = [];
|
||||
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
|
||||
let nodeInfo: Array<{
|
||||
radius_m: number | null;
|
||||
inner_angle_deg: number | null;
|
||||
violations: string[];
|
||||
}> = [];
|
||||
let nodeInfo: EditedNode[] = [];
|
||||
let minRadiusM = 0;
|
||||
/** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */
|
||||
let limitRadiusM = 0;
|
||||
let limitArcM = 0;
|
||||
/** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */
|
||||
let curveInfo: RoutePlanCurve[] = [];
|
||||
let curveInfo: EditedCurve[] = [];
|
||||
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
|
||||
let curveOn: boolean[] = [];
|
||||
let curveRadius: Array<number | null> = [];
|
||||
@@ -138,12 +123,74 @@ export async function openRouteEditModal(
|
||||
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
|
||||
let curveLock: CurveLock[] = [];
|
||||
let curveArc: Array<number | null> = [];
|
||||
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①) — 켜진 자리는 노드도 손잡이도
|
||||
* 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. */
|
||||
let apexLock: boolean[] = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
||||
let picked = -1;
|
||||
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
|
||||
let history: RouteEditHistory | null = null;
|
||||
let meta: VWorldMeta | null = null;
|
||||
let sheets: PreparedLayer[] = [];
|
||||
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */
|
||||
let contours: RouteEditContours | null = null;
|
||||
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
|
||||
let otherSheets: PreparedLayer[] = [];
|
||||
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
|
||||
let pickedContour = -1;
|
||||
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
|
||||
const crossPreview = createCrossPreview({
|
||||
projectId,
|
||||
side: overlay.querySelector<HTMLElement>(".b05-routeedit__side")!,
|
||||
request: () => ({
|
||||
vertices: planned.map(([x, y], index) => ({
|
||||
x,
|
||||
y,
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
min_radius_m: minRadiusM || 12,
|
||||
station_interval_m: stationIntervalM,
|
||||
}),
|
||||
});
|
||||
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const measure = createMeasureTool({
|
||||
projectId,
|
||||
stationIntervalM,
|
||||
line: () => (plannedLine.length ? plannedLine : planned),
|
||||
// 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다.
|
||||
toScreen: (vertex) => toScreen(vertex),
|
||||
isClosed: () => closed,
|
||||
onChange: () => {
|
||||
syncMeasureBox();
|
||||
draw();
|
||||
},
|
||||
});
|
||||
|
||||
/** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */
|
||||
function syncMeasureBox(): void {
|
||||
const on = measure.active();
|
||||
measureBox.hidden = !on;
|
||||
measureText.textContent = measure.hint();
|
||||
if (on && picked >= 0) {
|
||||
picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다.
|
||||
syncCurveBar();
|
||||
}
|
||||
}
|
||||
|
||||
/** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */
|
||||
let measureMode = false;
|
||||
measureButton.addEventListener("click", () => {
|
||||
measureMode = !measureMode;
|
||||
measureButton.classList.toggle("is-active", measureMode);
|
||||
if (!measureMode) measure.clear();
|
||||
});
|
||||
overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => {
|
||||
measure.clear(); // 닫으면 잰 것이 지워진다(㉔).
|
||||
measureMode = false;
|
||||
measureButton.classList.remove("is-active");
|
||||
syncMeasureBox();
|
||||
draw();
|
||||
});
|
||||
let view: ViewState = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -153,6 +200,15 @@ export async function openRouteEditModal(
|
||||
mapRect: computeMapRect(null, 0, 0),
|
||||
};
|
||||
let closed = false;
|
||||
/** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */
|
||||
const rotation = createMapRotation({
|
||||
overlay,
|
||||
size: () => view,
|
||||
onChange: () => {
|
||||
syncCurveBar(); // 떠 있는 패널도 돌아간 노드 옆으로 따라가야 한다.
|
||||
draw();
|
||||
},
|
||||
});
|
||||
|
||||
const close = (): void => {
|
||||
closed = true;
|
||||
@@ -199,111 +255,42 @@ export async function openRouteEditModal(
|
||||
return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)];
|
||||
}
|
||||
|
||||
function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void {
|
||||
if (points.length < 2) return;
|
||||
context.save();
|
||||
context.setLineDash(dash);
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = width;
|
||||
context.beginPath();
|
||||
points.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.stroke();
|
||||
context.restore();
|
||||
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. `metricToScreen` 이 선형이라
|
||||
* 100m 떨어진 두 점으로 잰다(1m 로 재면 반올림 오차가 그대로 비율에 실린다). */
|
||||
function pxPerMeter(): number {
|
||||
if (!meta) return 1;
|
||||
const [x0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
|
||||
const [x1] = metricToScreen(meta, view, meta.x_min + 100, meta.y_min);
|
||||
return Math.abs(x1 - x0) / 100;
|
||||
}
|
||||
|
||||
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
|
||||
const contourStepM = (): number =>
|
||||
contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
|
||||
|
||||
function draw(): void {
|
||||
if (closed) return;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
context.clearRect(0, 0, view.width, view.height);
|
||||
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
|
||||
context.fillRect(0, 0, view.width, view.height);
|
||||
|
||||
context.save();
|
||||
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
|
||||
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
|
||||
const band = meta
|
||||
? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M)
|
||||
: null;
|
||||
if (band) {
|
||||
context.beginPath();
|
||||
context.rect(band.x, band.y, band.width, band.height);
|
||||
context.clip();
|
||||
}
|
||||
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
|
||||
context.lineWidth = 0.8;
|
||||
for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot");
|
||||
context.restore();
|
||||
|
||||
strokePolyline(
|
||||
drawRouteEditScene(context, {
|
||||
view,
|
||||
toScreen,
|
||||
pxPerMeter: pxPerMeter(),
|
||||
hasMeta: meta !== null,
|
||||
contours,
|
||||
otherSheets,
|
||||
pickedContour,
|
||||
contourStepM: contourStepM(),
|
||||
rotationRad: rotation.radians(),
|
||||
uprightRad: rotation.uprightRad(),
|
||||
measure: measure.marks(),
|
||||
expected,
|
||||
[6, 5],
|
||||
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
|
||||
1.6,
|
||||
);
|
||||
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
|
||||
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
|
||||
strokePolyline(
|
||||
plannedLine.length ? plannedLine : planned,
|
||||
[],
|
||||
style.getPropertyValue("--map-route") || "#f97316",
|
||||
2.4,
|
||||
);
|
||||
|
||||
context.save();
|
||||
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
||||
context.lineWidth = 1;
|
||||
planned.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
|
||||
const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0;
|
||||
context.fillStyle = bad
|
||||
? style.getPropertyValue("--color-danger") || "#dc2626"
|
||||
: style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.beginPath();
|
||||
context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
|
||||
if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) {
|
||||
context.save();
|
||||
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
||||
context.beginPath();
|
||||
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
plannedLine,
|
||||
planned,
|
||||
nodeInfo,
|
||||
curveInfo,
|
||||
curveOn,
|
||||
picked,
|
||||
stationIntervalM,
|
||||
});
|
||||
|
||||
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
|
||||
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
|
||||
context.lineWidth = 2;
|
||||
curveInfo.forEach((curve) => {
|
||||
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
|
||||
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
|
||||
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
|
||||
const on = curveOn[curve.node_first] !== false;
|
||||
if (!on) return; // 곡선을 지운 자리에는 접선점도 없다.
|
||||
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
|
||||
const isPicked = curve.node_first === picked;
|
||||
[curve.start, curve.end].forEach((point) => {
|
||||
const [x, y] = toScreen([point[0], point[1]]);
|
||||
context.beginPath();
|
||||
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
|
||||
context.rect(x - size, y - size, size * 2, size * 2);
|
||||
context.fillStyle = isPicked
|
||||
? style.getPropertyValue("--map-route") || "#f97316"
|
||||
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
||||
context.fill();
|
||||
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.stroke();
|
||||
});
|
||||
});
|
||||
context.restore();
|
||||
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
|
||||
syncCurveBar();
|
||||
}
|
||||
@@ -312,7 +299,10 @@ export async function openRouteEditModal(
|
||||
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
|
||||
handleAtScreen(
|
||||
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5).
|
||||
curveInfo.filter((entry) => (curveLock[entry.node_first] ?? null) === null),
|
||||
curveInfo.filter(
|
||||
(entry) =>
|
||||
(curveLock[entry.node_first] ?? null) === null && apexLock[entry.node_first] !== true,
|
||||
),
|
||||
toScreen,
|
||||
px,
|
||||
py,
|
||||
@@ -346,12 +336,29 @@ export async function openRouteEditModal(
|
||||
function markEdited(): void {
|
||||
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
|
||||
applyArcLocks(planned, curveLock, curveArc, curveRadius);
|
||||
// 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④).
|
||||
// 하한은 **R 과 L 을 함께 본 값**이다 — 내각 155° 이상은 R 만 본다(2026-09-12 확정).
|
||||
applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM);
|
||||
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
|
||||
plannedLine = built.vertices;
|
||||
curveInfo = built.curves;
|
||||
nodeInfo = built.nodes;
|
||||
}
|
||||
|
||||
/** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로
|
||||
* 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */
|
||||
const routeHead = (): string =>
|
||||
`예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` +
|
||||
`계획노선 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` +
|
||||
`노드 ${planned.length}개`;
|
||||
|
||||
/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */
|
||||
const contourHint = (): string => {
|
||||
if (pickedContour < 0) return "등고선을 누르면 그 줄의 높이가 보입니다.";
|
||||
const level = contours?.layer.features[pickedContour]?.labelValue ?? null;
|
||||
return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`;
|
||||
};
|
||||
|
||||
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
|
||||
const curveHint = (): string =>
|
||||
curveSummary({
|
||||
@@ -359,90 +366,45 @@ export async function openRouteEditModal(
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
apexLock,
|
||||
curveCount: curveInfo.length,
|
||||
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
||||
minRadiusM,
|
||||
fresh: nodeInfo.length === 0,
|
||||
});
|
||||
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
|
||||
const curveLabelBox = createCurveLabel({
|
||||
onRadius: (value) => {
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
||||
},
|
||||
onArcLength: (value) => {
|
||||
if (picked < 0) return;
|
||||
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
||||
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveArc[picked] = value;
|
||||
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
|
||||
},
|
||||
onLock: (lock) => {
|
||||
if (picked < 0) return;
|
||||
curveLock[picked] = lock;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
|
||||
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
|
||||
if (lock === "arc") {
|
||||
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
|
||||
}
|
||||
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
|
||||
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
|
||||
applyEdit(
|
||||
lock === "radius"
|
||||
? "반지름을 고정했습니다."
|
||||
: lock === "arc"
|
||||
? "곡선 길이를 고정했습니다."
|
||||
: "고정을 풀었습니다.",
|
||||
);
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = on;
|
||||
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
|
||||
const curveBar = createCurveBar({
|
||||
canvas,
|
||||
state: () => ({
|
||||
picked,
|
||||
planned,
|
||||
nodeInfo,
|
||||
curveInfo,
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
curveArc,
|
||||
apexLock,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
}),
|
||||
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
|
||||
applyEdit: (message) => applyEdit(message),
|
||||
onUnselect: () => {
|
||||
picked = -1;
|
||||
syncCurveBar();
|
||||
draw();
|
||||
},
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
||||
function syncCurveBar(): void {
|
||||
if (!(picked > 0 && picked < planned.length - 1)) {
|
||||
curveLabelBox.hide();
|
||||
return;
|
||||
}
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [screenX, screenY] = toScreen(planned[picked]);
|
||||
curveLabelBox.show({
|
||||
seat: picked,
|
||||
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다.
|
||||
at: [screenX + rect.left, screenY + rect.top],
|
||||
centerDirection: pickedCurve
|
||||
? centerDirectionOf(
|
||||
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
||||
toScreen(pickedCurve.start),
|
||||
toScreen(pickedCurve.end),
|
||||
)
|
||||
: null,
|
||||
curveOn: curveOn[picked] !== false,
|
||||
radiusShown: shown,
|
||||
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
||||
lock: curveLock[picked] ?? null,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
});
|
||||
}
|
||||
const curveLabelBox = curveBar.label;
|
||||
const syncCurveBar = curveBar.sync;
|
||||
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
|
||||
function applyEdit(message: string, record = true): void {
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — ${message} ${curveHint()}`;
|
||||
status.textContent = `${routeHead()} — ${message} ${curveHint()}`;
|
||||
draw();
|
||||
if (record) history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
@@ -450,7 +412,7 @@ export async function openRouteEditModal(
|
||||
|
||||
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
||||
function snapshotNow(): RouteEditSnapshot {
|
||||
return { planned, curveOn, curveRadius, curveLock, curveArc, picked };
|
||||
return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
|
||||
}
|
||||
|
||||
const historyControls = bindHistoryControls({
|
||||
@@ -462,6 +424,7 @@ export async function openRouteEditModal(
|
||||
curveRadius = snapshot.curveRadius;
|
||||
curveLock = snapshot.curveLock;
|
||||
curveArc = snapshot.curveArc;
|
||||
apexLock = snapshot.apexLock;
|
||||
picked = snapshot.picked;
|
||||
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
|
||||
},
|
||||
@@ -479,37 +442,87 @@ export async function openRouteEditModal(
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (event.shiftKey || measureMode) {
|
||||
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
|
||||
void measure.pick(px, py);
|
||||
return;
|
||||
}
|
||||
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
|
||||
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
|
||||
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
|
||||
dragNode = nodeAt(px, py);
|
||||
dragHandle = dragNode >= 0 ? null : handleAt(px, py);
|
||||
const hitNode = nodeAt(px, py);
|
||||
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
|
||||
dragNode = hitNode >= 0 && apexLock[hitNode] !== true ? hitNode : -1;
|
||||
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
|
||||
dragMoved = false;
|
||||
if (dragNode >= 0) {
|
||||
picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
|
||||
if (hitNode >= 0) {
|
||||
picked = hitNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
|
||||
measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
|
||||
syncCurveBar();
|
||||
draw();
|
||||
} else if (dragHandle) {
|
||||
picked = dragHandle.node;
|
||||
measure.clear();
|
||||
syncCurveBar();
|
||||
draw();
|
||||
} else if (
|
||||
// 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나
|
||||
// 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다.
|
||||
((): boolean => {
|
||||
if (picked >= 0) {
|
||||
picked = -1;
|
||||
syncCurveBar();
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
) {
|
||||
/* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */
|
||||
} else if (
|
||||
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
|
||||
(() => {
|
||||
const chainage = stationAtScreen(
|
||||
plannedLine.length ? plannedLine : planned,
|
||||
toScreen,
|
||||
stationIntervalM,
|
||||
px,
|
||||
py,
|
||||
STATION_HIT_PX,
|
||||
);
|
||||
if (chainage === null) return false;
|
||||
void crossPreview.open(chainage);
|
||||
return true;
|
||||
})()
|
||||
) {
|
||||
/* 횡단 창이 떴다 — 더 집지 않는다. */
|
||||
} else if (contours) {
|
||||
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
|
||||
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
|
||||
const hit = hitPreparedLayer(contours.layer, view, px, py, CONTOUR_HIT_PX, contourStepM());
|
||||
if (hit !== pickedContour) {
|
||||
pickedContour = hit;
|
||||
status.textContent = `${routeHead()} — ${contourHint()} ${curveHint()}`;
|
||||
draw();
|
||||
}
|
||||
}
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
});
|
||||
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (dragHandle) {
|
||||
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
|
||||
const node = dragHandle.node;
|
||||
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
|
||||
if (moved) {
|
||||
planned[node] = moved.apex;
|
||||
curveRadius[node] = Math.round(moved.radius * 100) / 100;
|
||||
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
|
||||
// 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다.
|
||||
curveRadius[node] = Math.max(
|
||||
radiusFloorM(nodeInfo[node]?.inner_angle_deg, limitRadiusM, limitArcM),
|
||||
Math.round(moved.radius * 100) / 100,
|
||||
);
|
||||
curveOn[node] = true;
|
||||
picked = node;
|
||||
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
|
||||
@@ -517,22 +530,45 @@ export async function openRouteEditModal(
|
||||
dragMoved = true;
|
||||
markEdited();
|
||||
syncCurveBar();
|
||||
status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`;
|
||||
status.textContent = `${routeHead()} — 곡선을 잡는 중. ${curveHint()}`;
|
||||
draw();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dragNode >= 0) {
|
||||
// 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로
|
||||
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
|
||||
const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM);
|
||||
const previous = planned[dragNode];
|
||||
dragMoved = true;
|
||||
planned[dragNode] = toMetric(px, py);
|
||||
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
|
||||
if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) {
|
||||
// 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다.
|
||||
planned[dragNode] = previous;
|
||||
markEdited();
|
||||
status.textContent =
|
||||
`${routeHead()} — 하한에 걸려 더 못 옮깁니다` +
|
||||
`(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`;
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
|
||||
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
|
||||
status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`;
|
||||
status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`;
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default";
|
||||
const overNode = nodeAt(px, py);
|
||||
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
|
||||
canvas.style.cursor =
|
||||
overNode >= 0
|
||||
? apexLock[overNode]
|
||||
? "not-allowed"
|
||||
: "grab"
|
||||
: handleAt(px, py)
|
||||
? "grab"
|
||||
: "default";
|
||||
});
|
||||
|
||||
const endDrag = (event: PointerEvent): void => {
|
||||
@@ -542,6 +578,9 @@ export async function openRouteEditModal(
|
||||
if (dragMoved) {
|
||||
history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
// 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲).
|
||||
// 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초).
|
||||
void crossPreview.refresh();
|
||||
}
|
||||
dragNode = -1;
|
||||
dragHandle = null;
|
||||
@@ -552,8 +591,7 @@ export async function openRouteEditModal(
|
||||
|
||||
canvas.addEventListener("dblclick", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
const segment = segmentAt(px, py);
|
||||
if (segment < 0) return;
|
||||
planned.splice(segment + 1, 0, toMetric(px, py));
|
||||
@@ -562,6 +600,7 @@ export async function openRouteEditModal(
|
||||
curveRadius.splice(segment + 1, 0, null);
|
||||
curveLock.splice(segment + 1, 0, null);
|
||||
curveArc.splice(segment + 1, 0, null);
|
||||
apexLock.splice(segment + 1, 0, false);
|
||||
picked = segment + 1;
|
||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
});
|
||||
@@ -569,7 +608,7 @@ export async function openRouteEditModal(
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const index = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
|
||||
const index = nodeAt(...rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top));
|
||||
if (index < 0) return;
|
||||
if (planned.length <= 2) {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
@@ -580,6 +619,7 @@ export async function openRouteEditModal(
|
||||
curveRadius.splice(index, 1);
|
||||
curveLock.splice(index, 1);
|
||||
curveArc.splice(index, 1);
|
||||
apexLock.splice(index, 1);
|
||||
picked = -1;
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
});
|
||||
@@ -592,57 +632,18 @@ export async function openRouteEditModal(
|
||||
view = next;
|
||||
},
|
||||
getMeta: () => meta,
|
||||
// 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다.
|
||||
unrotateDelta: rotation.unrotateDelta,
|
||||
draw,
|
||||
});
|
||||
|
||||
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
|
||||
busy.hidden = false;
|
||||
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
|
||||
// 87.3 · 90.0 · 93.9 · 95.4초). 중간 취소를 안 만드는 대신, **얼마나 지났는지**를
|
||||
// 보여 사람이 멈춘 것인지 도는 것인지 알 수 있게 한다(계획서 0-2).
|
||||
const message = busy.querySelector("span")!;
|
||||
const started = Date.now();
|
||||
const tick = (): void => {
|
||||
const seconds = Math.round((Date.now() - started) / 1000);
|
||||
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
|
||||
};
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
try {
|
||||
await task();
|
||||
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
|
||||
clearDrafts(projectId);
|
||||
clearResults(projectId);
|
||||
showToast("노선을 다시 계산했습니다.", "success");
|
||||
close();
|
||||
await onApplied();
|
||||
} catch (error) {
|
||||
busy.hidden = true;
|
||||
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
|
||||
} finally {
|
||||
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
|
||||
}
|
||||
}
|
||||
|
||||
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
|
||||
if (planned.length < 2) {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
void runHeavy("계획노선 반영", () =>
|
||||
replanRoute(
|
||||
projectId,
|
||||
planned.map(([x, y], index) => ({
|
||||
x,
|
||||
y,
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
|
||||
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
|
||||
bindRouteApply({
|
||||
overlay,
|
||||
busy,
|
||||
projectId,
|
||||
nodes: () => ({ planned, curveOn, curveRadius }),
|
||||
close,
|
||||
onApplied,
|
||||
});
|
||||
|
||||
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
|
||||
@@ -658,6 +659,8 @@ export async function openRouteEditModal(
|
||||
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
|
||||
const nodes = plan.nodes ?? [];
|
||||
minRadiusM = plan.min_radius_m ?? 0;
|
||||
limitRadiusM = plan.limit_radius_m ?? 0;
|
||||
limitArcM = plan.limit_curve_length_m ?? 0;
|
||||
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
|
||||
const flat = flattenServerPlan(nodes, plan.curves ?? []);
|
||||
planned = flat.planned;
|
||||
@@ -667,6 +670,7 @@ export async function openRouteEditModal(
|
||||
curveRadius = flat.curveRadius;
|
||||
curveLock = flat.curveLock;
|
||||
curveArc = flat.curveArc;
|
||||
apexLock = flat.apexLock;
|
||||
picked = -1;
|
||||
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
@@ -675,9 +679,23 @@ export async function openRouteEditModal(
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
const normalizer = createNormalizer(drainage.meta);
|
||||
sheets = drainage.layers
|
||||
// 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
|
||||
otherSheets = drainage.layers
|
||||
.filter(([layer]) => layer !== "도엽_등고선")
|
||||
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
|
||||
.filter((layer): layer is PreparedLayer => layer !== null);
|
||||
contours = await loadRouteEditContours(
|
||||
projectId,
|
||||
drainage.meta,
|
||||
normalizer,
|
||||
drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null,
|
||||
{
|
||||
surfaceModelId: options.surfaceModelId ?? null,
|
||||
intervalM: options.contourIntervalM ?? 1,
|
||||
smooth: options.smooth ?? false,
|
||||
},
|
||||
);
|
||||
if (closed) return;
|
||||
resize();
|
||||
const xs = planned.map((vertex) => vertex[0]);
|
||||
const ys = planned.map((vertex) => vertex[1]);
|
||||
@@ -694,7 +712,8 @@ export async function openRouteEditModal(
|
||||
);
|
||||
view = { ...view, ...fitted };
|
||||
status.textContent =
|
||||
`노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
|
||||
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
|
||||
`${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` +
|
||||
curveHint();
|
||||
draw();
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Apply.ts
|
||||
* 계획노선 편집 모달의 **[확인]·[예상노선으로]** — 무거운 재계산과 대기 표시.
|
||||
*
|
||||
* 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는
|
||||
* 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이
|
||||
* 멈춘 것인지 도는 것인지 알 수 있게 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
export interface RouteApplyParams {
|
||||
overlay: HTMLElement;
|
||||
/** 화면 전체를 덮는 대기 막. 안에 `<span>` 한 개가 글을 받는다. */
|
||||
busy: HTMLElement;
|
||||
projectId: string;
|
||||
/** 지금 편집값 — 누른 순간에 읽는다. */
|
||||
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
|
||||
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
|
||||
close: () => void;
|
||||
onApplied: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */
|
||||
export function bindRouteApply(params: RouteApplyParams): void {
|
||||
const { overlay, busy, projectId } = params;
|
||||
|
||||
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
|
||||
busy.hidden = false;
|
||||
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
|
||||
// 87.3 · 90.0 · 93.9 · 95.4초).
|
||||
const message = busy.querySelector("span")!;
|
||||
const started = Date.now();
|
||||
const tick = (): void => {
|
||||
const seconds = Math.round((Date.now() - started) / 1000);
|
||||
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
|
||||
};
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
try {
|
||||
await task();
|
||||
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
|
||||
clearDrafts(projectId);
|
||||
clearResults(projectId);
|
||||
showToast("노선을 다시 계산했습니다.", "success");
|
||||
params.close();
|
||||
await params.onApplied();
|
||||
} catch (error) {
|
||||
busy.hidden = true;
|
||||
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
|
||||
} finally {
|
||||
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
|
||||
}
|
||||
}
|
||||
|
||||
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
|
||||
const { planned, curveOn, curveRadius } = params.nodes();
|
||||
if (planned.length < 2) {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
void runHeavy("계획노선 반영", () =>
|
||||
replanRoute(
|
||||
projectId,
|
||||
planned.map(([x, y], index) => ({
|
||||
x,
|
||||
y,
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
),
|
||||
);
|
||||
});
|
||||
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
|
||||
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Chrome.ts
|
||||
* 계획노선 편집 모달의 **뼈대** — 창·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다.
|
||||
*
|
||||
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고
|
||||
* 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다.
|
||||
*
|
||||
* **배치**(2026-09-12 사용자 지시 ⑩~⑬·⑱) — 아래 정보행을 없애고 지도 위 오버레이로 옮겼다.
|
||||
* 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
|
||||
* 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린
|
||||
* 기호(`↺`·`↻`)는 「한 바퀴 돈다」로 읽혀 한 칸씩 도는 동작과 안 맞았다. */
|
||||
const HALF_TURN_ICON = {
|
||||
ccw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 8a5 5 0 0 0-10 0" /><path d="M3 8 1.2 5.6" /><path d="M3 8 5.4 6.6" /></svg>`,
|
||||
cw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 8a5 5 0 0 1 10 0" /><path d="M13 8 14.8 5.6" /><path d="M13 8 10.6 6.6" /></svg>`,
|
||||
};
|
||||
|
||||
export interface RouteEditChrome {
|
||||
overlay: HTMLElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
status: HTMLElement;
|
||||
busy: HTMLElement;
|
||||
measureBox: HTMLElement;
|
||||
measureText: HTMLElement;
|
||||
measureButton: HTMLButtonElement;
|
||||
}
|
||||
|
||||
/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */
|
||||
export function createRouteEditChrome(): RouteEditChrome {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "b05-routeedit";
|
||||
overlay.innerHTML = `
|
||||
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
|
||||
<div class="b05-routeedit__head">
|
||||
<strong>계획노선 편집</strong>
|
||||
<span class="b05-routeedit__actions">
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
|
||||
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
|
||||
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="history-reset"
|
||||
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset">예상노선으로</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-act="apply">확인</button>
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__canvas-wrap">
|
||||
<canvas class="b05-routeedit__canvas"></canvas>
|
||||
<div class="b05-routeedit__hint">
|
||||
<span>노드 끌기 = 옮기기</span><span>노드 클릭 = R 라벨</span>
|
||||
<span>선 두 번 클릭 = 노드 추가</span><span>노드 오른쪽 클릭 = 삭제</span>
|
||||
<span>측점 눈금 클릭 = 횡단 미리보기</span><span>Shift+클릭 = 거리·기울기</span>
|
||||
<span>가운데(휠) 버튼 끌기 = 지도 이동</span><span>휠 = 확대</span>
|
||||
</div>
|
||||
<div class="b05-routeedit__spin">
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
|
||||
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
|
||||
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__measure" hidden>
|
||||
<span class="b05-routeedit__measure-text"></span>
|
||||
<button type="button" class="b05-routeedit__measure-close" aria-label="닫기"
|
||||
title="닫기">✕</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__info">
|
||||
<span class="b05-routeedit__status">노선을 읽는 중…</span>
|
||||
<span class="b05-routeedit__legend">
|
||||
<i class="is-expected"></i> 예상노선(원본)
|
||||
<i class="is-planned"></i> 계획노선
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routeedit__busy" hidden><span></span></div>
|
||||
</div>
|
||||
<div class="b05-routeedit__side"></div>`;
|
||||
document.body.append(overlay);
|
||||
|
||||
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
|
||||
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
|
||||
const measureBox = overlay.querySelector<HTMLElement>(".b05-routeedit__measure")!;
|
||||
const measureText = overlay.querySelector<HTMLElement>(".b05-routeedit__measure-text")!;
|
||||
const measureButton = overlay.querySelector<HTMLButtonElement>('[data-act="measure"]')!;
|
||||
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
|
||||
|
||||
return { overlay, canvas, status, busy, measureBox, measureText, measureButton };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Contour.ts
|
||||
* 계획노선 편집 모달이 바탕에 깔 **등고선 한 벌**을 고른다.
|
||||
*
|
||||
* **어느 등고선을 쓰나**(2026-09-12 사용자 지시 ⑥) — 도엽 등고선과 LAS 로 만든 등고선은
|
||||
* 서로 어긋난다. 노선은 실제 지형 위에 놓여야 하므로 **확정 지표면 모델이 있으면 LAS 쪽**을
|
||||
* 쓰고, 없는 프로젝트에서만 지금까지처럼 도엽 등고선을 쓴다.
|
||||
*
|
||||
* 둘은 생김새가 다르다 — 도엽은 위경도 GeoJSON(표고는 `등고수치` 속성), LAS 는 사업지
|
||||
* 좌표(m) 점렬(표고는 `level`)이다. 여기서 **같은 `PreparedLayer` 한 꼴로 맞춰** 내보내
|
||||
* 그리기·라벨·집기가 출처를 안 가리게 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
import {
|
||||
type GeoJsonCollection,
|
||||
type Normalizer,
|
||||
type PreparedLayer,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import {
|
||||
prepareLayer,
|
||||
prepareMetricPolylines,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
|
||||
/** 도엽 등고선의 표고 속성 이름 — B04 지도가 쓰는 것과 같은 키. */
|
||||
const SHEET_ELEVATION_KEYS = ["등고수치"];
|
||||
/** 표고를 못 읽었을 때 라벨 솎기에 쓸 간격(m). */
|
||||
const FALLBACK_INTERVAL_M = 5;
|
||||
/** LAS 등고선을 **몇 m 단위로** 낼지 — 도엽 등고선과 같은 눈금(2026-09-12 사용자 지시 ⑭).
|
||||
*
|
||||
* LAS 자료는 1m 간격으로 뽑혀 있어 그대로 쓰면 확대할 때 1m·2m 짜리까지 나온다. 서버에 5m
|
||||
* 로 다시 뽑아 달라고 하면 첫 한 번이 오래 걸리므로 **받아 둔 1m 자료에서 5의 배수만 고른다**
|
||||
* — 그림도 라벨도 도엽 쪽과 같은 눈금이 된다. */
|
||||
const LAS_CONTOUR_UNIT_M = 5;
|
||||
|
||||
export interface RouteEditContours {
|
||||
layer: PreparedLayer;
|
||||
/** 등고선 간격(m) — 라벨을 몇 줄마다 낼지 정하는 기준. */
|
||||
intervalM: number;
|
||||
source: "las" | "sheet";
|
||||
}
|
||||
|
||||
interface ContourResponse {
|
||||
contours: Array<{ level: number; coordinates: Array<[number, number, number]> }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바탕 등고선을 읽는다. 확정 지표면 모델이 있으면 LAS, 없으면 이미 받아 둔 도엽 컬렉션.
|
||||
*
|
||||
* LAS 쪽을 못 읽으면 **조용히 도엽으로 내려앉는다** — 등고선이 아예 없는 화면보다 낫고,
|
||||
* 어느 쪽을 쓰고 있는지는 `source` 로 나가 상태줄에 적힌다.
|
||||
*/
|
||||
export async function loadRouteEditContours(
|
||||
projectId: string,
|
||||
meta: VWorldMeta,
|
||||
normalizer: Normalizer,
|
||||
sheet: GeoJsonCollection | null,
|
||||
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
|
||||
): Promise<RouteEditContours> {
|
||||
if (options.surfaceModelId !== null) {
|
||||
// 받아 오는 간격은 프로젝트 설정 그대로(보관함에 이미 있는 파일을 쓰려는 것) —
|
||||
// **보이는 눈금**은 아래에서 5m 로 맞춘다.
|
||||
const interval = options.intervalM > 0 ? options.intervalM : 1;
|
||||
try {
|
||||
// 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다.
|
||||
const data = await fetchCachedJson<ContourResponse>(
|
||||
projectId,
|
||||
`${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` +
|
||||
`/contour?interval=${interval}&smooth=${options.smooth}`,
|
||||
);
|
||||
const lines = (data.contours ?? [])
|
||||
// 5m 단위만 남긴다 — 1m 자료를 다 들고 있으면 그리기·집기가 다섯 배로 무겁다.
|
||||
.filter((contour) => Math.abs(contour.level % LAS_CONTOUR_UNIT_M) < 1e-6)
|
||||
.map((contour) => ({
|
||||
points: contour.coordinates.map(([x, y]) => [x, y] as const),
|
||||
label: contour.level,
|
||||
}))
|
||||
.filter((line) => line.points.length >= 2);
|
||||
if (lines.length > 0) {
|
||||
return {
|
||||
layer: prepareMetricPolylines(lines, meta),
|
||||
intervalM: LAS_CONTOUR_UNIT_M,
|
||||
source: "las",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* 내려앉는다 — 아래 도엽 갈래로 이어 간다. */
|
||||
}
|
||||
}
|
||||
const layer = prepareLayer(sheet ?? undefined, normalizer, SHEET_ELEVATION_KEYS);
|
||||
return { layer, intervalM: inferIntervalM(layer), source: "sheet" };
|
||||
}
|
||||
|
||||
/** 도엽 등고선의 간격(m) — 표고 값들의 **가장 좁은 칸**을 간격으로 본다. */
|
||||
function inferIntervalM(layer: PreparedLayer): number {
|
||||
const levels = [
|
||||
...new Set(
|
||||
layer.features
|
||||
.map((feature) => feature.labelValue)
|
||||
.filter((value): value is number => value !== null),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
let smallest = Infinity;
|
||||
for (let index = 1; index < levels.length; index += 1) {
|
||||
const gap = levels[index] - levels[index - 1];
|
||||
if (gap > 0 && gap < smallest) smallest = gap;
|
||||
}
|
||||
return Number.isFinite(smallest) ? smallest : FALLBACK_INTERVAL_M;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Cross.ts
|
||||
* 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲).
|
||||
*
|
||||
* · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점을 셈해 여기에 낸다.
|
||||
* · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간**
|
||||
* 위 판의 것이 이리로 내려오고 새로 셈한 것이 위로 올라간다 — 전후를 나란히 본다.
|
||||
*
|
||||
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 ·
|
||||
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
|
||||
*
|
||||
* ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의
|
||||
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다.
|
||||
*
|
||||
* 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은
|
||||
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
|
||||
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||
import {
|
||||
buildCrossSvg,
|
||||
CROSS_PLOT_PAD,
|
||||
summarizeCross,
|
||||
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
|
||||
import { formatStation } from "./B05_Profile_Util_Station";
|
||||
|
||||
export interface CrossPreviewParams {
|
||||
projectId: string;
|
||||
/** 두 판이 들어앉을 오른쪽 세로 칸. */
|
||||
side: HTMLElement;
|
||||
/** 지금 편집값 — 셈을 부르는 순간에 읽는다. */
|
||||
request: () => {
|
||||
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
||||
min_radius_m: number;
|
||||
station_interval_m: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CrossPreviewWindow {
|
||||
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
|
||||
open: (chainageM: number) => Promise<void>;
|
||||
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface CrossPane {
|
||||
root: HTMLElement;
|
||||
/** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */
|
||||
show: (preview: CrossPreviewResponse | null, intervalM: number) => void;
|
||||
/** 기다리는 중임을 알린다. */
|
||||
wait: (text: string) => void;
|
||||
}
|
||||
|
||||
function createPane(title: string, empty: string): CrossPane {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b05-routeedit__cross";
|
||||
root.innerHTML = `
|
||||
<div class="b05-routeedit__cross-head">
|
||||
<strong class="b05-routeedit__cross-title">${title}</strong>
|
||||
<span class="b05-routeedit__cross-station"></span>
|
||||
</div>
|
||||
<div class="b05-routeedit__cross-box"></div>
|
||||
<div class="b05-routeedit__cross-foot">${empty}</div>`;
|
||||
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
|
||||
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
||||
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
|
||||
return {
|
||||
root,
|
||||
show(preview, intervalM) {
|
||||
box.replaceChildren();
|
||||
if (!preview) {
|
||||
station.textContent = "";
|
||||
foot.textContent = empty;
|
||||
return;
|
||||
}
|
||||
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
|
||||
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
|
||||
station.textContent = formatStation(preview.chainage_m, intervalM);
|
||||
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
|
||||
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
|
||||
const rect = box.getBoundingClientRect();
|
||||
const plot = buildCrossSvg(
|
||||
preview,
|
||||
Math.max(Math.round(rect.width), 240),
|
||||
Math.max(Math.round(rect.height), 150),
|
||||
);
|
||||
box.append(plot.svg);
|
||||
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
|
||||
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
|
||||
attachSvgZoomPan({
|
||||
svg: plot.svg,
|
||||
layer: plot.layer,
|
||||
widthPx: plot.width,
|
||||
heightPx: plot.height,
|
||||
pad: CROSS_PLOT_PAD,
|
||||
content: plot.content,
|
||||
wheelZoom: true,
|
||||
});
|
||||
foot.textContent = summarizeCross(preview);
|
||||
},
|
||||
wait(text) {
|
||||
box.replaceChildren();
|
||||
foot.textContent = text;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
|
||||
const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다.");
|
||||
const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다.");
|
||||
params.side.append(current.root, previous.root);
|
||||
|
||||
/** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */
|
||||
let watching: number | null = null;
|
||||
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */
|
||||
let shown: CrossPreviewResponse | null = null;
|
||||
/** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */
|
||||
let ticket = 0;
|
||||
|
||||
async function load(chainageM: number, keepPrevious: boolean): Promise<void> {
|
||||
const mine = ++ticket;
|
||||
const request = params.request();
|
||||
if (keepPrevious && shown) previous.show(shown, request.station_interval_m);
|
||||
current.wait("읽는 중…");
|
||||
try {
|
||||
const preview = await fetchCrossPreview(params.projectId, {
|
||||
...request,
|
||||
chainage_m: chainageM,
|
||||
});
|
||||
if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다.
|
||||
shown = preview;
|
||||
current.show(preview, request.station_interval_m);
|
||||
} catch (error) {
|
||||
if (mine !== ticket) return;
|
||||
shown = null;
|
||||
current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async open(chainageM) {
|
||||
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
|
||||
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
|
||||
previous.show(null, params.request().station_interval_m);
|
||||
watching = chainageM;
|
||||
await load(chainageM, false);
|
||||
},
|
||||
async refresh() {
|
||||
if (watching === null) return;
|
||||
await load(watching, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Cross_Draw.ts
|
||||
* 횡단 한 장을 **SVG 선**으로 짓는다 — 원지반선·기본 계획 횡단선과 아래 한 줄 요약.
|
||||
*
|
||||
* `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정).
|
||||
* 값은 서버가 B05·B06 정본으로 낸 것을 그대로 그린다 — 여기서 기하를 만들지 않는다.
|
||||
*
|
||||
* **왜 캔버스가 아니라 SVG 인가**(2026-09-12 사용자 지시 ③) — 캔버스는 한 번 찍으면 그림이라
|
||||
* 확대하면 뭉개진다. SVG 는 선이 그대로 남아 휠로 키워도 또렷하다. 확대·이동은 **공용
|
||||
* 조각**(`A00_Common/b_svg_zoom_pan.ts`)이 맡는다 — B06 횡단 카드와 한 코드다.
|
||||
*
|
||||
* **계획 횡단선은 절·성토 교차점까지만 그린다**(2026-09-12 사용자 지시 ②). 서버가 주는
|
||||
* `design_line` 은 계산 반폭 끝까지 이어지는데 **사면 끝 바깥은 손대지 않는 원지반**이라,
|
||||
* 그대로 그으면 지반선과 겹쳐 같은 지형이 두 번 그려진다. B06 횡단 카드는 2026-08-20 에
|
||||
* 같은 지적을 받아 이미 이렇게 끊고 있다(`appendCrossDesignOverlay`) — 교차점 찾기도
|
||||
* 그쪽이 쓰는 `toeOffsets` 를 그대로 부른다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { svgElement, svgText } from "@util/common_util_svg";
|
||||
import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan";
|
||||
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
|
||||
import { fillSlopeLengths, toeOffsets } from "./../B06_Section/B06_Section_UI_Cross_Fit";
|
||||
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||
|
||||
/** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */
|
||||
export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 };
|
||||
|
||||
/** 지은 한 판 — 확대·이동은 `layer` 에만 걸린다(범례·안내 글자는 제자리). */
|
||||
export interface CrossPlot {
|
||||
svg: SVGSVGElement;
|
||||
layer: SVGGElement;
|
||||
width: number;
|
||||
height: number;
|
||||
/** 실제로 선이 놓인 범위 — 확대했을 때 팬이 갈 수 있는 데까지. */
|
||||
content: ContentBounds;
|
||||
}
|
||||
|
||||
/** 성토사면 길이·절성토 면적 한 줄. */
|
||||
export function summarizeCross(preview: CrossPreviewResponse): string {
|
||||
const design = preview.design;
|
||||
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
|
||||
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
|
||||
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
|
||||
const lengths = fillSlopeLengths(asSection(preview));
|
||||
const sides = (["left", "right"] as const)
|
||||
.filter((side) => lengths[side] !== null)
|
||||
.map((side) => {
|
||||
const value = lengths[side]!;
|
||||
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
|
||||
return `${side === "left" ? "좌" : "우"} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
|
||||
});
|
||||
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
|
||||
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`;
|
||||
}
|
||||
|
||||
/** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */
|
||||
function asSection(preview: CrossPreviewResponse): CrossSection {
|
||||
return { samples: preview.samples, design: preview.design } as unknown as CrossSection;
|
||||
}
|
||||
|
||||
type Point = [number, number];
|
||||
|
||||
/**
|
||||
* 선을 **절·성토 교차점 사이만** 남긴다(2026-09-12 사용자 지시 ②).
|
||||
*
|
||||
* 교차점이 꼭짓점 사이에 떨어지면 그 자리에 점을 하나 만들어 **딱 거기서 끊는다** — 가까운
|
||||
* 꼭짓점에서 끊으면 사면 끝이 지반에 닿기 전에 멈춘 것처럼 보인다.
|
||||
* 그 측에서 교차점을 못 찾으면(사면이 지반을 못 만난 측점) 그쪽은 안 자른다.
|
||||
*/
|
||||
function clipToToes(line: Point[], low: number, high: number): Point[] {
|
||||
if (line.length < 2) return line;
|
||||
const inside = (point: Point): boolean => point[0] >= low - 1e-9 && point[0] <= high + 1e-9;
|
||||
const cutAt = (from: Point, to: Point, edge: number): Point => {
|
||||
const span = to[0] - from[0];
|
||||
const ratio = Math.abs(span) <= 1e-9 ? 0 : (edge - from[0]) / span;
|
||||
return [edge, from[1] + (to[1] - from[1]) * ratio];
|
||||
};
|
||||
const out: Point[] = [];
|
||||
line.forEach((point, index) => {
|
||||
if (inside(point)) out.push(point);
|
||||
const next = line[index + 1];
|
||||
if (!next) return;
|
||||
// 경계를 넘어가는 구간은 경계 자리에 점을 하나 세운다. 가까운 경계부터 넣어야 순서가 산다.
|
||||
[low, high]
|
||||
.filter((edge) => (point[0] - edge) * (next[0] - edge) < 0)
|
||||
.sort((a, b) => Math.abs(a - point[0]) - Math.abs(b - point[0]))
|
||||
.forEach((edge) => out.push(cutAt(point, next, edge)));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원지반선과 기본 계획 횡단선을 한 판에 짓는다. 좌(+offset)가 화면 왼쪽이다
|
||||
* (`generate_sections` cad_exchange 규약과 같은 방향).
|
||||
*
|
||||
* **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
|
||||
* 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
|
||||
*/
|
||||
export function buildCrossSvg(
|
||||
preview: CrossPreviewResponse,
|
||||
width: number,
|
||||
height: number,
|
||||
): CrossPlot {
|
||||
const svg = svgElement("svg", {
|
||||
viewBox: `0 0 ${width} ${height}`,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
preserveAspectRatio: "none",
|
||||
class: "b05-routeedit__cross-svg",
|
||||
});
|
||||
const layer = svgElement("g", { class: "b05-routeedit__cross-plot" });
|
||||
|
||||
const ground = preview.samples
|
||||
.filter((sample) => sample.valid && sample.elevation_m !== null)
|
||||
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point);
|
||||
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
|
||||
// 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다.
|
||||
const design = clipToToes(
|
||||
(preview.design?.design_line ?? []).map(
|
||||
(point) => [point.offset_m, point.elevation_m] as Point,
|
||||
),
|
||||
toes.right ?? -Infinity, // −offset = 우
|
||||
toes.left ?? Infinity, // +offset = 좌
|
||||
);
|
||||
|
||||
const all = [...ground, ...design];
|
||||
if (all.length < 2) {
|
||||
svg.append(layer);
|
||||
return { svg, layer, width, height, content: { x: 0, y: 0, width, height } };
|
||||
}
|
||||
|
||||
const offsets = all.map((point) => point[0]);
|
||||
const heights = all.map((point) => point[1]);
|
||||
const minOffset = Math.min(...offsets);
|
||||
const maxOffset = Math.max(...offsets);
|
||||
const minZ = Math.min(...heights);
|
||||
const maxZ = Math.max(...heights);
|
||||
const spanX = maxOffset - minOffset || 1;
|
||||
const spanZ = maxZ - minZ || 1;
|
||||
const plotWidth = Math.max(width - CROSS_PLOT_PAD.left - CROSS_PLOT_PAD.right, 1);
|
||||
const plotHeight = Math.max(height - CROSS_PLOT_PAD.top - CROSS_PLOT_PAD.bottom, 1);
|
||||
const scale = Math.min(plotWidth / spanX, plotHeight / spanZ);
|
||||
const centerOffset = (minOffset + maxOffset) / 2;
|
||||
const centerZ = (minZ + maxZ) / 2;
|
||||
const toScreen = (point: Point): Point => [
|
||||
width / 2 + (centerOffset - point[0]) * scale,
|
||||
height / 2 + (centerZ - point[1]) * scale,
|
||||
];
|
||||
|
||||
const path = (points: Point[], className: string): void => {
|
||||
if (points.length < 2) return;
|
||||
layer.append(
|
||||
svgElement("polyline", {
|
||||
class: className,
|
||||
points: points.map((point) => toScreen(point).join(",")).join(" "),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
|
||||
const [centerX] = toScreen([0, centerZ]);
|
||||
layer.append(
|
||||
svgElement("line", {
|
||||
class: "b05-routeedit__cross-axis",
|
||||
x1: centerX,
|
||||
y1: CROSS_PLOT_PAD.top / 2,
|
||||
x2: centerX,
|
||||
y2: height - CROSS_PLOT_PAD.bottom / 2,
|
||||
}),
|
||||
);
|
||||
path(ground, "b05-routeedit__cross-ground");
|
||||
path(design, "b05-routeedit__cross-design");
|
||||
|
||||
// 범례·눈금 안내는 **확대해도 제자리**다 — 확대 묶음 밖에 둔다.
|
||||
svg.append(layer);
|
||||
svg.append(
|
||||
svgText("원지반", {
|
||||
class: "b05-routeedit__cross-key is-ground",
|
||||
x: CROSS_PLOT_PAD.left,
|
||||
y: 13,
|
||||
"text-anchor": "start",
|
||||
}),
|
||||
svgText("기본 계획 횡단", {
|
||||
class: "b05-routeedit__cross-key is-design",
|
||||
x: width - CROSS_PLOT_PAD.right,
|
||||
y: 13,
|
||||
"text-anchor": "end",
|
||||
}),
|
||||
svgText(
|
||||
`좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
|
||||
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
|
||||
{
|
||||
class: "b05-routeedit__cross-key",
|
||||
x: width / 2,
|
||||
y: height - 6,
|
||||
"text-anchor": "middle",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const screens = all.map(toScreen);
|
||||
const xs = screens.map((point) => point[0]);
|
||||
const ys = screens.map((point) => point[1]);
|
||||
return {
|
||||
svg,
|
||||
layer,
|
||||
width,
|
||||
height,
|
||||
content: {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_CurveBar.ts
|
||||
* 곡선 조작 패널의 **배선** — 어느 꺾임점을 만질지 정하고, 칸에서 들어온 값을 편집값에
|
||||
* 옮겨 적는다. 패널을 그리고 자리를 잡는 일은 `_Label` 몫이다.
|
||||
*
|
||||
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과
|
||||
* 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다.
|
||||
*
|
||||
* **R 과 곡선 길이는 한 쌍**(L = R·Δ) — 어느 쪽으로 들어와도 **반지름 한 값**으로 바꿔
|
||||
* 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
curveOptionalAt,
|
||||
deflectionRad,
|
||||
radiusFloorM,
|
||||
type CurveLock,
|
||||
} from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
centerDirectionOf,
|
||||
createCurveLabel,
|
||||
type CurveLabel,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
|
||||
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
|
||||
export interface CurveBarState {
|
||||
picked: number;
|
||||
planned: Vertex[];
|
||||
nodeInfo: EditedNode[];
|
||||
curveInfo: EditedCurve[];
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
curveLock: CurveLock[];
|
||||
curveArc: Array<number | null>;
|
||||
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①). */
|
||||
apexLock: boolean[];
|
||||
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||||
limitRadiusM: number;
|
||||
limitArcM: number;
|
||||
}
|
||||
|
||||
export interface CurveBarParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
state: () => CurveBarState;
|
||||
/** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */
|
||||
toScreen: (vertex: Vertex) => [number, number];
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
|
||||
applyEdit: (message: string) => void;
|
||||
/** 고른 꺾임점을 푼다 — 닫기 단추와 「빈 곳 누르기」가 부른다(계획서 0-9 ㉖). */
|
||||
onUnselect: () => void;
|
||||
}
|
||||
|
||||
export interface CurveBar {
|
||||
label: CurveLabel;
|
||||
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
|
||||
sync: () => void;
|
||||
}
|
||||
|
||||
export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
const label = createCurveLabel({
|
||||
onClose: () => params.onUnselect(),
|
||||
onRadius: (value) => {
|
||||
const { picked, curveRadius } = params.state();
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
||||
},
|
||||
onArcLength: (value) => {
|
||||
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
|
||||
if (picked < 0) return;
|
||||
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
||||
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveArc[picked] = value;
|
||||
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
||||
params.applyEdit(
|
||||
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
|
||||
);
|
||||
},
|
||||
onLock: (lock) => {
|
||||
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
|
||||
if (picked < 0) return;
|
||||
curveLock[picked] = lock;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
|
||||
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
|
||||
if (lock === "arc") {
|
||||
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
|
||||
}
|
||||
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
|
||||
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
|
||||
params.applyEdit(
|
||||
lock === "radius"
|
||||
? "반지름을 고정했습니다."
|
||||
: lock === "arc"
|
||||
? "곡선 길이를 고정했습니다."
|
||||
: "고정을 풀었습니다.",
|
||||
);
|
||||
},
|
||||
onApexLock: (locked) => {
|
||||
const { picked, apexLock } = params.state();
|
||||
if (picked < 0) return;
|
||||
apexLock[picked] = locked;
|
||||
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
|
||||
params.applyEdit(locked ? "교각점을 고정했습니다." : "교각점 고정을 풀었습니다.");
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
const { picked, curveOn } = params.state();
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = on;
|
||||
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
||||
function sync(): void {
|
||||
const {
|
||||
picked,
|
||||
planned,
|
||||
nodeInfo,
|
||||
curveInfo,
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
apexLock,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
} = params.state();
|
||||
if (!(picked > 0 && picked < planned.length - 1)) {
|
||||
label.hide();
|
||||
return;
|
||||
}
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
||||
const innerAngle = nodeInfo[picked]?.inner_angle_deg ?? null;
|
||||
const optional = curveOptionalAt(innerAngle);
|
||||
const deflection = deflectionRad(innerAngle);
|
||||
const rect = params.canvas.getBoundingClientRect();
|
||||
const [screenX, screenY] = params.toScreen(planned[picked]);
|
||||
label.show({
|
||||
seat: picked,
|
||||
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
|
||||
at: [screenX + rect.left, screenY + rect.top],
|
||||
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
|
||||
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
|
||||
bounds: {
|
||||
left: rect.left + 8,
|
||||
top: rect.top + 8,
|
||||
right: rect.right - 8,
|
||||
bottom: rect.bottom - 8,
|
||||
},
|
||||
centerDirection: pickedCurve
|
||||
? centerDirectionOf(
|
||||
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
||||
params.toScreen(pickedCurve.start),
|
||||
params.toScreen(pickedCurve.end),
|
||||
)
|
||||
: null,
|
||||
curveOn: curveOn[picked] !== false,
|
||||
radiusShown: shown,
|
||||
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
||||
lock: curveLock[picked] ?? null,
|
||||
apexLocked: apexLock[picked] === true,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
curveOptional: optional,
|
||||
// 칸이 막는 하한은 **그 자리 값**이다 — R 칸은 L 하한까지 환산해 함께 보고,
|
||||
// 곡선을 생략해도 되는 자리(내각 155° 이상)는 L 하한을 안 건다(2026-09-12 확정).
|
||||
limitRadiusM: radiusFloorM(innerAngle, limitRadiusM, limitArcM),
|
||||
limitArcM: optional ? 0 : limitArcM,
|
||||
});
|
||||
}
|
||||
|
||||
return { label, sync };
|
||||
}
|
||||
@@ -12,6 +12,12 @@
|
||||
*
|
||||
* ⚠ 접선 자리가 모자라면 그리기 단계에서 R 을 줄이는 것은 그대로다(`buildEditedPolyline`).
|
||||
* 그것은 **그려지는 값**만 줄이고 잠가 둔 값은 안 건드린다 — 자리를 넓히면 되돌아온다.
|
||||
*
|
||||
* **하한은 R 과 L 둘 다 본다**(2026-09-12 사용자 확정 「둘 다 지켜야 함」). L = R·Δ 라
|
||||
* 한 자리의 하한은 결국 **반지름 하나**로 모인다 — `radiusFloorM` 이 그 값을 낸다.
|
||||
* 다만 **내각 155° 이상은 뺀다**(별표2 Ⅰ.2.다.(1) 「내각 155도 이상 되는 장소는 곡선을
|
||||
* 설치하지 아니할 수 있다」). 거기서 L 을 채우라고 하면 교각이 0 에 가까워 R 이 수십 배로
|
||||
* 부풀고, **아무것도 안 고쳤는데 노선이 휜다**(실측: 내각 174.8° 자리가 R 12m → 55m).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -20,11 +26,43 @@ import {
|
||||
type EditedNode,
|
||||
type Vertex,
|
||||
} from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import { deflectionRad } from "./B05_Profile_UI_RouteEdit_Label";
|
||||
|
||||
/** 무엇을 붙들고 있나. */
|
||||
export type CurveLock = "radius" | "arc" | null;
|
||||
|
||||
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
|
||||
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
|
||||
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
|
||||
return ((180 - innerAngleDeg) * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/** 곡선을 **안 둘 수 있는** 내각(도) — 별표2 Ⅰ.2.다.(1) · Ⅰ.3.라.(1). */
|
||||
export const CURVE_OPTIONAL_INNER_ANGLE_DEG = 155;
|
||||
|
||||
/** 그 자리에서 곡선을 생략해도 되나 — 내각이 155° 이상이면 그렇다. */
|
||||
export function curveOptionalAt(innerAngleDeg: number | null | undefined): boolean {
|
||||
return innerAngleDeg !== null && innerAngleDeg !== undefined
|
||||
? innerAngleDeg >= CURVE_OPTIONAL_INNER_ANGLE_DEG
|
||||
: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 꺾임점의 **못 넘는 반지름 하한**(m) — R 하한과 L 하한을 함께 본 값.
|
||||
*
|
||||
* L = R·Δ 이므로 「L ≥ 하한」은 「R ≥ 하한/Δ」와 같다. 둘 중 큰 쪽이 그 자리의 하한이다.
|
||||
* 내각 155° 이상(곡선 생략 가능)이거나 교각을 모르면 **R 하한만** 본다.
|
||||
*/
|
||||
export function radiusFloorM(
|
||||
innerAngleDeg: number | null | undefined,
|
||||
limitRadiusM: number,
|
||||
limitArcM: number,
|
||||
): number {
|
||||
if (!(limitArcM > 0) || curveOptionalAt(innerAngleDeg)) return limitRadiusM;
|
||||
const deflection = deflectionRad(innerAngleDeg);
|
||||
if (!(deflection > 1e-9)) return limitRadiusM;
|
||||
return Math.max(limitRadiusM, limitArcM / deflection);
|
||||
}
|
||||
|
||||
/**
|
||||
* **곡선 길이를 잠근 자리**의 반지름을 지금 교각에 맞춰 다시 잡는다(`curveRadius` 를 고침).
|
||||
*
|
||||
@@ -48,11 +86,81 @@ export function applyArcLocks(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* **하한(R·L)을 지키도록 지정값을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정).
|
||||
*
|
||||
* **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을 쓰고 있고, 여기서
|
||||
* 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다.
|
||||
*
|
||||
* 하한은 `radiusFloorM` 이 R·L 을 함께 본 값이다 — 내각 155° 이상은 R 하한만 본다.
|
||||
*/
|
||||
export function applyCurveLimits(
|
||||
planned: Vertex[],
|
||||
curveOn: ReadonlyArray<boolean>,
|
||||
curveRadius: Array<number | null>,
|
||||
limitRadiusM: number,
|
||||
limitArcM = 0,
|
||||
): void {
|
||||
if (limitRadiusM <= 0 && limitArcM <= 0) return;
|
||||
for (let seat = 1; seat < planned.length - 1; seat += 1) {
|
||||
if (curveOn[seat] === false) continue;
|
||||
const current = curveRadius[seat];
|
||||
if (current === null || current === undefined) continue;
|
||||
const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]);
|
||||
const floor = radiusFloorM(inner, limitRadiusM, limitArcM);
|
||||
if (current < floor) curveRadius[seat] = floor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드마다 **하한(R·L)** 을 얼마나 밑돌고 있나(m). 다 지키고 있으면 0.
|
||||
*
|
||||
* 재는 자는 `radiusFloorM` 이다 — L 하한도 반지름으로 환산해 함께 본다. 거의 곧은 자리
|
||||
* (내각 155° 이상)는 곡선을 안 둬도 되는 자리라 R 하한만 본다. 그 예외가 없으면 그런
|
||||
* 자리에서 **노드를 한 뼘도 못 옮긴다**.
|
||||
*
|
||||
* 노드를 옮기면 접선 자리가 모자라 그리기 단계에서 R 이 눌릴 수 있다. 그 눌림까지 막으려면
|
||||
* 옮기기 자체를 되돌려야 하므로, **옮기기 전보다 나빠진 자리가 있는지**만 견준다 — 이미
|
||||
* 하한을 밑돌던 옛 노선도 그대로 고칠 수 있어야 하기 때문이다(2026-09-12).
|
||||
*
|
||||
* ⚠ 가장 큰 값 하나로 견주면 안 된다. 크게 밑도는 자리가 이미 있으면 **다른 자리가 새로
|
||||
* 무너져도 최댓값이 안 움직여** 그냥 통과한다(실화면에서 「기준 미달 1곳 → 2곳」이 그대로
|
||||
* 지나갔다). 자리마다 따로 견준다.
|
||||
*/
|
||||
export function curveShortfalls(
|
||||
nodes: ReadonlyArray<EditedNode>,
|
||||
limitRadiusM: number,
|
||||
limitArcM = 0,
|
||||
): number[] {
|
||||
return nodes.map((node) => {
|
||||
if (node.radius_m === null) return 0;
|
||||
const floor = radiusFloorM(node.inner_angle_deg, limitRadiusM, limitArcM);
|
||||
if (floor <= 0) return 0;
|
||||
return Math.max(0, floor - node.radius_m);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 하한을 지키던 자리가 **이번 걸음에 처음으로 무너졌나**. 자리 수가 달라지면(넣기·지우기)
|
||||
* 안 따진다.
|
||||
*
|
||||
* ⚠ 「조금이라도 나빠졌으면 막기」로 두면 **이미 하한을 밑돌던 옛 노선을 아예 못 고친다** —
|
||||
* 그 옆 노드를 1px 만 건드려도 밑돌던 값이 미세하게 더 내려가 첫 걸음부터 막혔다(2026-09-12
|
||||
* 실화면). 이미 무너진 자리는 그대로 두고(붉은 표시는 남는다), **지키고 있던 자리가 넘어가는
|
||||
* 것만** 막는다.
|
||||
*/
|
||||
export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean {
|
||||
if (before.length !== after.length) return false;
|
||||
return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6);
|
||||
}
|
||||
|
||||
export interface CurveSummaryInput {
|
||||
nodeCount: number;
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
curveLock: CurveLock[];
|
||||
/** 교각점을 못 박은 자리 — 「고정 N곳」에 함께 센다(2026-09-12 사용자 지시 ①). */
|
||||
apexLock: boolean[];
|
||||
/** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */
|
||||
curveCount: number;
|
||||
/** 법정 기준을 못 맞춘 자리 수. */
|
||||
@@ -68,7 +176,8 @@ export function curveSummary(input: CurveSummaryInput): string {
|
||||
(on, index) => !on && index > 0 && index < input.nodeCount - 1,
|
||||
).length;
|
||||
const forced = input.curveRadius.filter((value) => value !== null).length;
|
||||
const locked = input.curveLock.filter((lock) => lock !== null).length;
|
||||
const locked =
|
||||
input.curveLock.filter((lock) => lock !== null).length + input.apexLock.filter(Boolean).length;
|
||||
const edits = [
|
||||
off ? `곡선 지움 ${off}곳` : "",
|
||||
forced ? `R 지정 ${forced}곳` : "",
|
||||
@@ -96,6 +205,7 @@ export interface FlattenedPlan {
|
||||
curveRadius: Array<number | null>;
|
||||
curveLock: CurveLock[];
|
||||
curveArc: Array<number | null>;
|
||||
apexLock: boolean[];
|
||||
}
|
||||
|
||||
interface ServerNode {
|
||||
@@ -137,6 +247,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
|
||||
curveRadius: [],
|
||||
curveLock: [],
|
||||
curveArc: [],
|
||||
apexLock: [],
|
||||
};
|
||||
nodes.forEach((node, index) => {
|
||||
if (dropped.has(index)) return;
|
||||
@@ -154,6 +265,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
|
||||
out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
|
||||
out.curveLock.push(null);
|
||||
out.curveArc.push(null);
|
||||
out.apexLock.push(false);
|
||||
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
|
||||
});
|
||||
return out;
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface RouteEditSnapshot {
|
||||
curveLock: Array<"radius" | "arc" | null>;
|
||||
/** 길이를 붙들었을 때의 그 길이(m). */
|
||||
curveArc: Array<number | null>;
|
||||
/** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */
|
||||
apexLock: boolean[];
|
||||
picked: number;
|
||||
}
|
||||
|
||||
@@ -50,6 +52,7 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
|
||||
curveRadius: [...snapshot.curveRadius],
|
||||
curveLock: [...snapshot.curveLock],
|
||||
curveArc: [...snapshot.curveArc],
|
||||
apexLock: [...snapshot.apexLock],
|
||||
picked: snapshot.picked,
|
||||
};
|
||||
}
|
||||
@@ -64,6 +67,7 @@ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
|
||||
if (a.curveRadius[index] !== b.curveRadius[index]) return false;
|
||||
if (a.curveLock[index] !== b.curveLock[index]) return false;
|
||||
if (a.curveArc[index] !== b.curveArc[index]) return false;
|
||||
if (a.apexLock[index] !== b.apexLock[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -29,12 +29,16 @@ export interface RouteEditNavigationParams {
|
||||
getView: () => ViewState;
|
||||
setView: (next: ViewState) => void;
|
||||
getMeta: () => VWorldMeta | null;
|
||||
/** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다.
|
||||
* 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */
|
||||
unrotateDelta?: (dx: number, dy: number) => [number, number];
|
||||
draw: () => void;
|
||||
}
|
||||
|
||||
/** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */
|
||||
export function bindRouteEditNavigation(params: RouteEditNavigationParams): void {
|
||||
const { canvas, getView, setView, getMeta, draw } = params;
|
||||
const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]);
|
||||
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
|
||||
|
||||
canvas.addEventListener(
|
||||
@@ -53,8 +57,10 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
|
||||
const ratio = scale / view.scale;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다.
|
||||
const cursorX = event.clientX - rect.left - rect.width / 2;
|
||||
const cursorY = event.clientY - rect.top - rect.height / 2;
|
||||
const [cursorX, cursorY] = unrotateDelta(
|
||||
event.clientX - rect.left - rect.width / 2,
|
||||
event.clientY - rect.top - rect.height / 2,
|
||||
);
|
||||
setView({
|
||||
...view,
|
||||
scale,
|
||||
@@ -84,11 +90,8 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
|
||||
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!dragStart) return;
|
||||
setView({
|
||||
...getView(),
|
||||
offsetX: dragStart.offsetX + event.clientX - dragStart.x,
|
||||
offsetY: dragStart.offsetY + event.clientY - dragStart.y,
|
||||
});
|
||||
const [dx, dy] = unrotateDelta(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||
setView({ ...getView(), offsetX: dragStart.offsetX + dx, offsetY: dragStart.offsetY + dy });
|
||||
draw();
|
||||
});
|
||||
|
||||
@@ -181,6 +184,98 @@ export function segmentAtScreen(
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 노선 위 한 점 — 어디를 짚었나와 그 자리의 누가거리. */
|
||||
export interface RoutePointHit {
|
||||
/** 사업지 좌표(m). */
|
||||
point: [number, number];
|
||||
/** 시점에서 노선을 따라간 거리(m). */
|
||||
chainageM: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 노선(그려지는 폴리라인) 위에서 **클릭에 가장 가까운 점**과 그 누가거리. 멀면 null.
|
||||
*
|
||||
* 직선·곡선을 가리지 않는다(계획서 0-9 ⑤) — 원호도 이미 정점으로 펴져 있어 같은 선분 훑기로
|
||||
* 잡힌다. 누가거리는 선분 길이를 누적해 구하므로 노선 길이 표기와 같은 값을 본다.
|
||||
*/
|
||||
export function routePointAtScreen(
|
||||
line: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
px: number,
|
||||
py: number,
|
||||
maxPx: number,
|
||||
): RoutePointHit | null {
|
||||
let best: RoutePointHit | null = null;
|
||||
let bestDistance = maxPx;
|
||||
let travelled = 0;
|
||||
for (let index = 0; index < line.length - 1; index += 1) {
|
||||
const from = line[index];
|
||||
const to = line[index + 1];
|
||||
const segmentM = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
||||
const [ax, ay] = toScreen(from);
|
||||
const [bx, by] = toScreen(to);
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lengthSquared = dx * dx + dy * dy || 1;
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
|
||||
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = {
|
||||
point: [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t],
|
||||
chainageM: travelled + segmentM * t,
|
||||
};
|
||||
}
|
||||
travelled += segmentM;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 클릭에 가장 가까운 **규칙 측점**의 누가거리(m). 그만큼 안에 없으면 null(계획서 0-9 ⑧).
|
||||
*
|
||||
* 눈금을 그리는 `drawStationTicks` 와 **같은 자리**를 짚는다 — 선분 길이를 누적해 측점 간격
|
||||
* 마다 한 점씩 보간한다. 눈금이 보이는 자리를 눌렀는데 안 잡히면 안 되기 때문이다.
|
||||
*/
|
||||
export function stationAtScreen(
|
||||
line: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
intervalM: number,
|
||||
px: number,
|
||||
py: number,
|
||||
maxPx: number,
|
||||
): number | null {
|
||||
if (line.length < 2 || !(intervalM > 0)) return null;
|
||||
const cumulative: number[] = [0];
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
cumulative.push(
|
||||
cumulative[index - 1] +
|
||||
Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]),
|
||||
);
|
||||
}
|
||||
const total = cumulative[cumulative.length - 1];
|
||||
let best: number | null = null;
|
||||
let bestDistance = maxPx;
|
||||
let cursor = 1;
|
||||
for (let chainage = 0; chainage <= total; chainage += intervalM) {
|
||||
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
||||
const back = line[cursor - 1];
|
||||
const front = line[cursor];
|
||||
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
|
||||
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
|
||||
const [x, y] = toScreen([
|
||||
back[0] + (front[0] - back[0]) * ratio,
|
||||
back[1] + (front[1] - back[1]) * ratio,
|
||||
]);
|
||||
const distance = Math.hypot(x - px, y - py);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = chainage;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null.
|
||||
*
|
||||
* **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는
|
||||
|
||||
@@ -11,19 +11,20 @@
|
||||
*
|
||||
* **자리**(2026-09-07 사용자 지시)
|
||||
* · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라
|
||||
* 안에 두면 가장자리에서 **잘린다**. 화면 밖으로도 넘어갈 수 있어야 한다.
|
||||
* 안에 두면 가장자리에서 **잘린다**.
|
||||
* · 다만 **지도 칸 밖으로는 안 나간다**(2026-09-12 사용자 지적 ⑨) — 상자 밖이나 하단
|
||||
* 정보행 위로 넘어가면 지금 무엇을 고치는지 모달 안에서 안 보인다. 「잘리지 않게」와
|
||||
* 「상자 밖으로 나가게」는 다른 문제여서 자리 계산에서만 가둔다.
|
||||
* · 자동 자리는 **곡선 중심의 반대쪽**, **16방위**로 잡는다(4방위는 대각 자리에서 곡선을 물었다).
|
||||
* · 머리를 잡아 **손으로 옮길 수 있다**. 옮긴 자리는 그 꺾임점을 보는 동안 유지되고,
|
||||
* 다른 꺾임점을 고르면 자동 자리로 돌아간다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import { CURVE_OPTIONAL_INNER_ANGLE_DEG, type CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
|
||||
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
|
||||
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
|
||||
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
|
||||
return ((180 - innerAngleDeg) * Math.PI) / 180;
|
||||
}
|
||||
// `deflectionRad` 는 화면을 안 만지는 셈이라 `_Edits.ts` 로 옮겼다(2026-09-12) — 순수
|
||||
// 함수가 DOM 파일에 얹혀 있으면 시험이 화면째 들여와야 한다. 재수출로 부르던 자리를 지킨다.
|
||||
export { deflectionRad } from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
|
||||
/** 곡선 중심이 있는 **화면 방향**(단위벡터) — 패널을 그 반대쪽에 붙이는 데 쓴다.
|
||||
*
|
||||
@@ -60,20 +61,33 @@ export interface CurveLabelState {
|
||||
at: [number, number];
|
||||
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */
|
||||
centerDirection: [number, number] | null;
|
||||
/** 패널이 넘어가면 안 되는 테두리(화면 좌표) — 보통 모달의 지도 칸. 없으면 안 가둔다. */
|
||||
bounds?: { left: number; top: number; right: number; bottom: number };
|
||||
curveOn: boolean;
|
||||
radiusShown: number | null;
|
||||
/** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
|
||||
arcLengthShown: number | null;
|
||||
lock: CurveLock;
|
||||
/** 교각점(이 꺾임점) 자리를 못 박았나 — 켜면 끌어서 못 옮긴다(2026-09-12 사용자 지시 ①). */
|
||||
apexLocked: boolean;
|
||||
/** 내각 155° 이상이라 **곡선을 안 둬도 되는** 자리인가(별표2 Ⅰ.2.다.(1)). */
|
||||
curveOptional: boolean;
|
||||
innerAngleDeg: number | null;
|
||||
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||||
limitRadiusM?: number;
|
||||
limitArcM?: number;
|
||||
}
|
||||
|
||||
export interface CurveLabelHandlers {
|
||||
/** 닫기 단추 — 고른 꺾임점을 푼다(계획서 0-9 ㉖). */
|
||||
onClose: () => void;
|
||||
onRadius: (value: number | null) => void;
|
||||
onArcLength: (value: number | null) => void;
|
||||
onCurveOn: (on: boolean) => void;
|
||||
/** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */
|
||||
onLock: (lock: CurveLock) => void;
|
||||
/** 교각점 자리를 못 박거나 푼다. */
|
||||
onApexLock: (locked: boolean) => void;
|
||||
}
|
||||
|
||||
export interface CurveLabel {
|
||||
@@ -106,6 +120,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
<div class="b05-routeedit__label-head">
|
||||
<span class="b05-routeedit__curve-label"></span>
|
||||
<button type="button" class="b05-routeedit__label-toggle" data-act="curve-toggle"></button>
|
||||
<button type="button" class="b05-routeedit__label-close" data-act="curve-close"
|
||||
aria-label="닫기" title="닫기">✕</button>
|
||||
</div>
|
||||
<label class="b05-routeedit__curve-field">반지름
|
||||
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
|
||||
@@ -119,8 +135,11 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
|
||||
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
||||
</label>
|
||||
<span class="b05-routeedit__curve-info"></span>
|
||||
<span class="b05-routeedit__curve-note">칸을 비우면 자동</span>`;
|
||||
<div class="b05-routeedit__curve-field">교각점
|
||||
<button type="button" class="b05-routeedit__lock" data-act="lock-apex"
|
||||
title="교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다">고정</button>
|
||||
</div>
|
||||
<span class="b05-routeedit__curve-info"></span>`;
|
||||
document.body.append(root);
|
||||
|
||||
const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!;
|
||||
@@ -130,6 +149,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
const arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-arc")!;
|
||||
const lockRadius = root.querySelector<HTMLButtonElement>('[data-act="lock-radius"]')!;
|
||||
const lockArc = root.querySelector<HTMLButtonElement>('[data-act="lock-arc"]')!;
|
||||
const lockApex = root.querySelector<HTMLButtonElement>('[data-act="lock-apex"]')!;
|
||||
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
|
||||
|
||||
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||||
@@ -139,20 +159,38 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
|
||||
let curveOn = true;
|
||||
let lock: CurveLock = null;
|
||||
let apexLocked = false;
|
||||
let seat = -1;
|
||||
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
|
||||
let manual: [number, number] | null = null;
|
||||
let anchor: [number, number] = [0, 0];
|
||||
/** 지금 자리의 하한 — 칸이 여기서 멈춘다. 0이면 제한 없음. */
|
||||
let limitRadius = 0;
|
||||
let limitArc = 0;
|
||||
/** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */
|
||||
let limit: CurveLabelState["bounds"];
|
||||
|
||||
const numberOf = (input: HTMLInputElement): number | null => {
|
||||
/** 칸에서 읽은 값. **하한 아래는 하한에서 멈추고, 멈춘 값을 칸에 되적어 보인다**
|
||||
* (2026-09-12 사용자 확정 「아예 못 넘게 막음」) — 조용히 바꾸면 왜 안 먹었는지 모른다. */
|
||||
const numberOf = (input: HTMLInputElement, floor: number): number | null => {
|
||||
const value = Number(input.value);
|
||||
return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
if (input.value.trim() === "" || !Number.isFinite(value) || value <= 0) return null;
|
||||
if (floor > 0 && value < floor) {
|
||||
input.value = String(floor);
|
||||
return floor;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius)));
|
||||
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc)));
|
||||
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius)));
|
||||
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc)));
|
||||
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
|
||||
root
|
||||
.querySelector('[data-act="curve-close"]')!
|
||||
.addEventListener("click", () => handlers.onClose());
|
||||
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
|
||||
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
|
||||
// 교각점 고정은 **곡선 유무와 무관**하다 — 곡선을 지운 자리도 꺾임점 자리는 못 박을 수 있다.
|
||||
lockApex.addEventListener("click", () => handlers.onApexLock(!apexLocked));
|
||||
|
||||
// ── 머리를 잡아 옮기기 — 곡선을 가리면 손으로 치울 수 있어야 한다 ──
|
||||
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
|
||||
@@ -169,8 +207,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
});
|
||||
head.addEventListener("pointermove", (event) => {
|
||||
if (!dragFrom) return;
|
||||
const left = dragFrom.left + event.clientX - dragFrom.x;
|
||||
const top = dragFrom.top + event.clientY - dragFrom.y;
|
||||
// 끄는 동안에도 테두리를 지킨다 — 놓은 뒤에만 가두면 손이 간 자리에서 패널이 튄다.
|
||||
const [left, top] = clamp(
|
||||
dragFrom.left + event.clientX - dragFrom.x,
|
||||
dragFrom.top + event.clientY - dragFrom.y,
|
||||
root.offsetWidth,
|
||||
root.offsetHeight,
|
||||
limit,
|
||||
);
|
||||
root.style.left = `${Math.round(left)}px`;
|
||||
root.style.top = `${Math.round(top)}px`;
|
||||
// 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다.
|
||||
@@ -183,7 +227,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
head.addEventListener("pointerup", stopDrag);
|
||||
head.addEventListener("pointercancel", stopDrag);
|
||||
|
||||
/** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. */
|
||||
/** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다.
|
||||
* 마지막에 **테두리 안으로 가둔다** — 자동 자리든 손으로 옮긴 자리든 같이 갇힌다. */
|
||||
function place(state: CurveLabelState): void {
|
||||
const width = root.offsetWidth;
|
||||
const height = root.offsetHeight;
|
||||
@@ -193,12 +238,32 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
: ([1, 0] as [number, number]);
|
||||
const distance = GAP_PX + boxReach(away[0], away[1], width, height);
|
||||
anchor = [nx + away[0] * distance - width / 2, ny + away[1] * distance - height / 2];
|
||||
const left = anchor[0] + (manual ? manual[0] : 0);
|
||||
const top = anchor[1] + (manual ? manual[1] : 0);
|
||||
const [left, top] = clamp(
|
||||
anchor[0] + (manual ? manual[0] : 0),
|
||||
anchor[1] + (manual ? manual[1] : 0),
|
||||
width,
|
||||
height,
|
||||
state.bounds,
|
||||
);
|
||||
root.style.left = `${Math.round(left)}px`;
|
||||
root.style.top = `${Math.round(top)}px`;
|
||||
}
|
||||
|
||||
/** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */
|
||||
function clamp(
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
bounds: CurveLabelState["bounds"],
|
||||
): [number, number] {
|
||||
if (!bounds) return [left, top];
|
||||
return [
|
||||
Math.max(bounds.left, Math.min(left, bounds.right - width)),
|
||||
Math.max(bounds.top, Math.min(top, bounds.bottom - height)),
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
show(state) {
|
||||
if (state.seat !== seat) {
|
||||
@@ -207,6 +272,13 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
}
|
||||
curveOn = state.curveOn;
|
||||
lock = state.lock;
|
||||
apexLocked = state.apexLocked;
|
||||
limit = state.bounds;
|
||||
limitRadius = state.limitRadiusM ?? 0;
|
||||
limitArc = state.limitArcM ?? 0;
|
||||
// 칸 자체에도 하한을 박아 화살표·스피너가 그 아래로 안 내려가게 한다.
|
||||
radius.min = limitRadius > 0 ? String(limitRadius) : "1";
|
||||
arc.min = limitArc > 0 ? String(limitArc) : "1";
|
||||
root.hidden = false;
|
||||
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
|
||||
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
|
||||
@@ -216,15 +288,23 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
lockArc.disabled = !state.curveOn;
|
||||
lockRadius.classList.toggle("is-on", lock === "radius");
|
||||
lockArc.classList.toggle("is-on", lock === "arc");
|
||||
lockApex.classList.toggle("is-on", apexLocked);
|
||||
radius.value =
|
||||
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
|
||||
arc.value =
|
||||
state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10);
|
||||
const inner = state.innerAngleDeg;
|
||||
const held =
|
||||
lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음";
|
||||
// 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로,
|
||||
// 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다.
|
||||
// 내각 155° 이상은 법이 곡선을 안 둬도 된다고 한 자리다 — L 하한을 안 거는 까닭이
|
||||
// 여기서 보여야 한다(2026-09-12 사용자 확정).
|
||||
info.textContent = state.curveOn
|
||||
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}`
|
||||
? inner
|
||||
? `내각 ${Math.round(inner)}°` +
|
||||
(state.curveOptional
|
||||
? ` · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능`
|
||||
: "")
|
||||
: ""
|
||||
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
||||
place(state);
|
||||
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Measure.ts
|
||||
* 계획노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤).
|
||||
*
|
||||
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
|
||||
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다.
|
||||
*
|
||||
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
|
||||
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
|
||||
* 끄는 동안에는 한 번도 부르지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
|
||||
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { formatStation } from "./B05_Profile_Util_Station";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
|
||||
const MEASURE_HIT_PX = 14;
|
||||
|
||||
interface MeasurePoint extends RoutePointHit {
|
||||
/** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */
|
||||
z: number | null;
|
||||
}
|
||||
|
||||
export interface MeasureToolParams {
|
||||
projectId: string;
|
||||
/** 규칙 측점 간격(m) — 측점 표기에 쓴다. */
|
||||
stationIntervalM: number;
|
||||
/** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
|
||||
line: () => Vertex[];
|
||||
toScreen: (vertex: Vertex) => [number, number];
|
||||
/** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */
|
||||
isClosed: () => boolean;
|
||||
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export interface MeasureMark {
|
||||
point: Vertex;
|
||||
/** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
|
||||
chainageM: number;
|
||||
}
|
||||
|
||||
export interface MeasureTool {
|
||||
/** 찍힌 자리(0~2개) — 그리기가 쓴다. */
|
||||
marks: () => MeasureMark[];
|
||||
/** 잰 값 한 줄. 찍은 것이 없으면 빈 문자열. */
|
||||
hint: () => string;
|
||||
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
|
||||
active: () => boolean;
|
||||
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
|
||||
pick: (px: number, py: number) => Promise<void>;
|
||||
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
|
||||
let picked: MeasurePoint[] = [];
|
||||
|
||||
const hint = (): string => {
|
||||
if (picked.length === 0) return "";
|
||||
const first = picked[0];
|
||||
if (picked.length === 1) {
|
||||
return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
|
||||
}
|
||||
const second = picked[1];
|
||||
const span = Math.abs(second.chainageM - first.chainageM);
|
||||
const head =
|
||||
`구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
|
||||
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
|
||||
if (first.z === null || second.z === null || span <= 1e-6) {
|
||||
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
|
||||
}
|
||||
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
|
||||
const rise = second.z - first.z;
|
||||
return (
|
||||
`${head} · 지반고 ${first.z.toFixed(1)} → ${second.z.toFixed(1)}m` +
|
||||
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })),
|
||||
hint,
|
||||
active: () => picked.length > 0,
|
||||
clear() {
|
||||
if (picked.length === 0) return;
|
||||
picked = [];
|
||||
params.onChange();
|
||||
},
|
||||
async pick(px, py) {
|
||||
const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX);
|
||||
if (!hit) {
|
||||
picked = []; // 노선을 빗나가면 재던 것을 접는다.
|
||||
params.onChange();
|
||||
return;
|
||||
}
|
||||
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
|
||||
params.onChange();
|
||||
if (picked.length < 2) return;
|
||||
const asked = picked;
|
||||
try {
|
||||
const heights = await fetchRouteElevations(
|
||||
params.projectId,
|
||||
asked.map((entry) => entry.point),
|
||||
);
|
||||
if (params.isClosed() || picked !== asked) return; // 그 사이 다시 찍었으면 버린다.
|
||||
asked.forEach((entry, index) => {
|
||||
entry.z = heights[index] ?? null;
|
||||
});
|
||||
} catch {
|
||||
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
|
||||
}
|
||||
params.onChange();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Render.ts
|
||||
* 계획노선 편집 모달의 **그리기** — 등고선·예상노선·계획노선·노드·곡선 손잡이,
|
||||
* 그 위에 시점·종점·규칙측점 눈금.
|
||||
*
|
||||
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과
|
||||
* 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `scene` 으로 받는다.
|
||||
*
|
||||
* 측점 눈금은 **B04 지도·배수유역도와 같은 한 곳**(`drawStationTicks`)을 부른다 — 표기가
|
||||
* 화면마다 갈리면 같은 자리를 두 이름으로 부르게 된다(계획서 0-9 ②).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
drawPreparedFeature,
|
||||
drawPreparedLabels,
|
||||
drawPreparedLayer,
|
||||
layerScreenBounds,
|
||||
normalizedToScreen,
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { formatStation } from "./B05_Profile_Util_Station";
|
||||
|
||||
/** 노드 반지름(px). */
|
||||
const NODE_R = 4;
|
||||
/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07).
|
||||
*
|
||||
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
|
||||
* 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을
|
||||
* 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */
|
||||
const CONTOUR_BAND_M = 300;
|
||||
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다.
|
||||
* 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */
|
||||
const CURVE_HANDLE_PX = 5;
|
||||
/** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */
|
||||
const OUTWARD_PX = 26;
|
||||
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
|
||||
const MEASURE_COLOR = "#22c55e";
|
||||
|
||||
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
|
||||
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
|
||||
let total = 0;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
total += Math.hypot(
|
||||
points[index][0] - points[index - 1][0],
|
||||
points[index][1] - points[index - 1][1],
|
||||
);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export interface RouteEditScene {
|
||||
view: ViewState;
|
||||
/** 사업지 좌표(m) → 캔버스 px. */
|
||||
toScreen: (vertex: Vertex) => [number, number];
|
||||
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 이 값으로 정한다. */
|
||||
pxPerMeter: number;
|
||||
/** 도엽 메타를 읽었나 — 못 읽었으면 등고선 띠를 씌우지 않는다. */
|
||||
hasMeta: boolean;
|
||||
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */
|
||||
contours: RouteEditContours | null;
|
||||
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
|
||||
otherSheets: ReadonlyArray<PreparedLayer>;
|
||||
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
|
||||
pickedContour: number;
|
||||
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 **같은 값**을 봐야 한다. */
|
||||
contourStepM: number;
|
||||
expected: ReadonlyArray<Vertex>;
|
||||
/** 그려 보이는 계획노선(원호 포함). */
|
||||
plannedLine: ReadonlyArray<Vertex>;
|
||||
/** 잡아 옮기는 노드(꺾임점). */
|
||||
planned: ReadonlyArray<Vertex>;
|
||||
nodeInfo: ReadonlyArray<EditedNode>;
|
||||
curveInfo: ReadonlyArray<EditedCurve>;
|
||||
curveOn: ReadonlyArray<boolean>;
|
||||
/** 지금 고른 꺾임점. 없으면 -1. */
|
||||
picked: number;
|
||||
/** 규칙 측점 간격(m). */
|
||||
stationIntervalM: number;
|
||||
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */
|
||||
measure: ReadonlyArray<{ point: Vertex; chainageM: number }>;
|
||||
/** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */
|
||||
rotationRad: number;
|
||||
/** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */
|
||||
uprightRad: number;
|
||||
}
|
||||
|
||||
/** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */
|
||||
function upright(
|
||||
context: CanvasRenderingContext2D,
|
||||
radians: number,
|
||||
x: number,
|
||||
y: number,
|
||||
paint: () => void,
|
||||
): void {
|
||||
if (!radians) {
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
context.save();
|
||||
context.translate(x, y);
|
||||
context.rotate(radians);
|
||||
context.translate(-x, -y);
|
||||
paint();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void {
|
||||
const { view, toScreen } = scene;
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const line = scene.plannedLine.length ? scene.plannedLine : scene.planned;
|
||||
context.clearRect(0, 0, view.width, view.height);
|
||||
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
|
||||
context.fillRect(0, 0, view.width, view.height);
|
||||
|
||||
// 여기서부터 **그림 전체**가 돈다 — 글자도 함께 돈다(CAD 도면과 같은 방식, 사용자 지시 ⑯).
|
||||
// 바탕칠은 돌리기 **전에** 해 두었다 — 돌린 뒤에 칠하면 모서리에 빈 곳이 생긴다.
|
||||
context.save();
|
||||
if (scene.rotationRad) {
|
||||
context.translate(view.width / 2, view.height / 2);
|
||||
context.rotate(scene.rotationRad);
|
||||
context.translate(-view.width / 2, -view.height / 2);
|
||||
}
|
||||
|
||||
context.save();
|
||||
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
|
||||
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
|
||||
const band = scene.hasMeta ? contourBandRect(line as Vertex[], toScreen, CONTOUR_BAND_M) : null;
|
||||
if (band) {
|
||||
context.beginPath();
|
||||
context.rect(band.x, band.y, band.width, band.height);
|
||||
context.clip();
|
||||
}
|
||||
context.strokeStyle = style.getPropertyValue("--map-sheet-stream") || "#2563eb";
|
||||
context.lineWidth = 1.2;
|
||||
// 바탕이 LAS 면 세류선도 **등고선이 있는 데까지만** 그린다(2026-09-12 사용자 지시 ⑮) —
|
||||
// 도엽 하천중심선은 도엽 전체를 덮어 LAS 자료 밖까지 길게 뻗는다.
|
||||
const lasBox =
|
||||
scene.contours?.source === "las" ? layerScreenBounds(scene.contours.layer, view) : null;
|
||||
context.save();
|
||||
if (lasBox) {
|
||||
context.beginPath();
|
||||
context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height);
|
||||
context.clip();
|
||||
}
|
||||
for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot");
|
||||
context.restore();
|
||||
if (scene.contours) {
|
||||
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
|
||||
const everyM = scene.contourStepM;
|
||||
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
|
||||
context.lineWidth = 0.8;
|
||||
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
|
||||
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
|
||||
if (scene.pickedContour >= 0) {
|
||||
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
|
||||
context.lineWidth = 2.6;
|
||||
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
|
||||
}
|
||||
// 높이값 라벨은 여기서 안 낸다 — 노선·눈금 **뒤에** 그려야 안 묻힌다(맨 아래 참고).
|
||||
}
|
||||
context.restore();
|
||||
|
||||
strokePolyline(
|
||||
context,
|
||||
toScreen,
|
||||
scene.expected,
|
||||
[6, 5],
|
||||
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
|
||||
1.6,
|
||||
);
|
||||
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
|
||||
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
|
||||
strokePolyline(
|
||||
context,
|
||||
toScreen,
|
||||
line,
|
||||
[],
|
||||
style.getPropertyValue("--map-route") || "#f97316",
|
||||
2.4,
|
||||
);
|
||||
|
||||
context.save();
|
||||
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
||||
context.lineWidth = 1;
|
||||
scene.planned.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
|
||||
const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0;
|
||||
context.fillStyle = bad
|
||||
? style.getPropertyValue("--color-danger") || "#dc2626"
|
||||
: style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.beginPath();
|
||||
context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
|
||||
if (
|
||||
scene.curveOn.length &&
|
||||
!scene.curveOn[index] &&
|
||||
index > 0 &&
|
||||
index < scene.planned.length - 1
|
||||
) {
|
||||
context.save();
|
||||
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
||||
context.beginPath();
|
||||
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
|
||||
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
|
||||
context.lineWidth = 2;
|
||||
scene.curveInfo.forEach((curve) => {
|
||||
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
|
||||
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
|
||||
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
|
||||
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
|
||||
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
|
||||
const isPicked = curve.node_first === scene.picked;
|
||||
[curve.start, curve.end].forEach((point) => {
|
||||
const [x, y] = toScreen([point[0], point[1]]);
|
||||
context.beginPath();
|
||||
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
|
||||
context.rect(x - size, y - size, size * 2, size * 2);
|
||||
context.fillStyle = isPicked
|
||||
? style.getPropertyValue("--map-route") || "#f97316"
|
||||
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
||||
context.fill();
|
||||
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.stroke();
|
||||
});
|
||||
});
|
||||
context.restore();
|
||||
|
||||
drawStationMarks(context, scene, line);
|
||||
drawMeasureMarks(context, scene, line);
|
||||
// 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
|
||||
// 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
|
||||
drawContourLabels(context, scene, band, style);
|
||||
context.restore(); // 회전 끝
|
||||
}
|
||||
|
||||
/** 등고 높이값 라벨 — **그림 맨 위**에 얹는다(2026-09-12 사용자 지시 ⑦).
|
||||
*
|
||||
* 고른 가닥의 큰 이름표도 여기서 낸다. 줄과 라벨은 **같은 눈금**(`contourStepM`)으로 솎아
|
||||
* 그린 줄에만 숫자가 붙는다 — 촘촘함은 부르는 쪽이 그 눈금으로 정한다. */
|
||||
function drawContourLabels(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
band: { x: number; y: number; width: number; height: number } | null,
|
||||
style: CSSStyleDeclaration,
|
||||
): void {
|
||||
if (!scene.contours) return;
|
||||
context.save();
|
||||
if (band) {
|
||||
context.beginPath();
|
||||
context.rect(band.x, band.y, band.width, band.height);
|
||||
context.clip();
|
||||
}
|
||||
context.font = "10px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
drawPreparedLabels(
|
||||
context,
|
||||
scene.contours.layer,
|
||||
scene.view,
|
||||
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
|
||||
scene.contourStepM,
|
||||
scene.uprightRad,
|
||||
);
|
||||
if (scene.pickedContour >= 0) drawPickedContourLabel(context, scene, scene.view);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
|
||||
function drawMeasureMarks(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
line: ReadonlyArray<Vertex>,
|
||||
): void {
|
||||
if (scene.measure.length === 0) return;
|
||||
context.save();
|
||||
if (scene.measure.length >= 2) {
|
||||
const span = spanBetween(line, scene.measure[0], scene.measure[1]);
|
||||
if (span.length >= 2) {
|
||||
context.strokeStyle = MEASURE_COLOR;
|
||||
context.lineWidth = 4;
|
||||
context.beginPath();
|
||||
span.forEach((vertex, index) => {
|
||||
const [x, y] = scene.toScreen(vertex);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
context.lineWidth = 2.4;
|
||||
context.strokeStyle = MEASURE_COLOR;
|
||||
context.font = "bold 11px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
scene.measure.forEach((mark, index) => {
|
||||
const [x, y] = scene.toScreen(mark.point);
|
||||
context.fillStyle = "rgba(255,255,255,0.95)";
|
||||
context.beginPath();
|
||||
context.arc(x, y, 7, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.fillStyle = "#14532d";
|
||||
upright(context, scene.uprightRad, x, y, () => context.fillText(index === 0 ? "a" : "b", x, y));
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 점 사이의 노선 조각 — **누가거리로** 자른다(계획서 0-9 ㉕).
|
||||
*
|
||||
* ⚠ 예전에는 **가장 가까운 정점**으로 잘랐다. 노선이 되꺾이는 자리에서는 a 옆에 b 쪽 정점이
|
||||
* 더 가까이 붙어 있어 엉뚱한 자리를 골랐고, 그 결과 초록 띠가 노선을 벗어나 **삼각형으로
|
||||
* 얽혔다**(2026-09-12 사용자 화면). 찍을 때 이미 누가거리를 알고 있으므로 그것으로 자른다.
|
||||
*/
|
||||
function spanBetween(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
from: { point: Vertex; chainageM: number },
|
||||
to: { point: Vertex; chainageM: number },
|
||||
): Vertex[] {
|
||||
const low = Math.min(from.chainageM, to.chainageM);
|
||||
const high = Math.max(from.chainageM, to.chainageM);
|
||||
const head = from.chainageM <= to.chainageM ? from.point : to.point;
|
||||
const tail = from.chainageM <= to.chainageM ? to.point : from.point;
|
||||
const inside: Vertex[] = [];
|
||||
let travelled = 0;
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
const step = Math.hypot(
|
||||
line[index][0] - line[index - 1][0],
|
||||
line[index][1] - line[index - 1][1],
|
||||
);
|
||||
// 정점의 누가거리가 두 점 사이면 그대로 잇는다 — 사이에 없는 정점은 건너뛴다.
|
||||
if (travelled > low && travelled < high) inside.push(line[index - 1]);
|
||||
travelled += step;
|
||||
}
|
||||
return [head, ...inside, tail];
|
||||
}
|
||||
|
||||
/** 고른 등고선의 **높이값을 크게** 붙인다(계획서 0-9 ㉘) — 색만 바뀌면 몇 m 인지 안 보인다. */
|
||||
function drawPickedContourLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
view: ViewState,
|
||||
): void {
|
||||
const feature = scene.contours?.layer.features[scene.pickedContour];
|
||||
if (!feature || feature.labelValue === null) return;
|
||||
const [x, y] = normalizedToScreen(view, feature.labelAnchorX, feature.labelAnchorY);
|
||||
const text = `${feature.labelValue}m`;
|
||||
upright(context, scene.uprightRad, x, y, () => {
|
||||
context.save();
|
||||
context.font = "bold 13px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
const width = context.measureText(text).width + 10;
|
||||
context.fillStyle = "#7c3aed";
|
||||
context.fillRect(x - width / 2, y - 9, width, 18);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillText(text, x, y);
|
||||
context.restore();
|
||||
});
|
||||
}
|
||||
|
||||
/** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */
|
||||
function drawStationMarks(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
line: ReadonlyArray<Vertex>,
|
||||
): void {
|
||||
if (line.length < 2) return;
|
||||
// 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게.
|
||||
drawStationTicks(
|
||||
context,
|
||||
line.map(([x, y]) => ({ x, y })),
|
||||
{
|
||||
intervalM: scene.stationIntervalM,
|
||||
pxPerMeter: scene.pxPerMeter,
|
||||
toScreen: (x, y) => scene.toScreen([x, y]),
|
||||
uprightRad: scene.uprightRad,
|
||||
},
|
||||
);
|
||||
const total = polylineLengthM(line);
|
||||
const last = line.length - 1;
|
||||
endLabel(context, scene, line[0], line[1], `시점 ${formatStation(0, scene.stationIntervalM)}`);
|
||||
endLabel(
|
||||
context,
|
||||
scene,
|
||||
line[last],
|
||||
line[last - 1],
|
||||
`종점 ${formatStation(total, scene.stationIntervalM)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 시점·종점 이름표 — 측점 라벨보다 크고 짙게 찍어 양 끝을 한눈에 알게 한다.
|
||||
*
|
||||
* 자리는 **노선 바깥쪽**(끝점에서 노선을 등진 방향)이다. 위로만 띄웠더니 같은 자리의 측점
|
||||
* 라벨(0+0.0 · 50+0.0)과 겹쳐 두 글자가 포개졌다 — 측점 라벨은 노선에 **직각**으로 나가므로
|
||||
* 노선을 따라 밀면 서로 안 물린다(2026-09-12 실화면). */
|
||||
function endLabel(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
at: Vertex,
|
||||
inward: Vertex,
|
||||
text: string,
|
||||
): void {
|
||||
const [x0, y0] = scene.toScreen(at);
|
||||
const [x1, y1] = scene.toScreen(inward);
|
||||
const length = Math.hypot(x0 - x1, y0 - y1) || 1;
|
||||
const x = x0 + ((x0 - x1) / length) * OUTWARD_PX;
|
||||
const y = y0 + ((y0 - y1) / length) * OUTWARD_PX;
|
||||
context.save();
|
||||
if (scene.uprightRad) {
|
||||
context.translate(x, y);
|
||||
context.rotate(scene.uprightRad);
|
||||
context.translate(-x, -y);
|
||||
}
|
||||
context.font = "bold 12px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
const width = context.measureText(text).width + 10;
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.9)";
|
||||
context.fillRect(x - width / 2, y - 26, width, 17);
|
||||
context.strokeStyle = "#f97316";
|
||||
context.lineWidth = 1;
|
||||
context.strokeRect(x - width / 2, y - 26, width, 17);
|
||||
context.fillStyle = "#111111";
|
||||
context.fillText(text, x, y - 17.5);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function strokePolyline(
|
||||
context: CanvasRenderingContext2D,
|
||||
toScreen: (vertex: Vertex) => [number, number],
|
||||
points: ReadonlyArray<Vertex>,
|
||||
dash: number[],
|
||||
color: string,
|
||||
width: number,
|
||||
): void {
|
||||
if (points.length < 2) return;
|
||||
context.save();
|
||||
context.setLineDash(dash);
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = width;
|
||||
context.beginPath();
|
||||
points.forEach((vertex, index) => {
|
||||
const [x, y] = toScreen(vertex);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Rotate.ts
|
||||
* 지도 회전 — 반시계·시계 단추 하나씩, 누를 때마다 **그림 전체**가 한 칸 돈다
|
||||
* (계획서 0-9 ⑯, 2026-09-12 사용자 지시).
|
||||
*
|
||||
* 축은 **캔버스 한가운데**다. 그리기는 캔버스 변환으로 한 번에 돌리고(글자도 함께 돈다 —
|
||||
* CAD 도면과 같은 방식), 집기는 들어온 화면 자리를 **거꾸로 돌려** 안 돌린 좌표로 바꿔 쓴다.
|
||||
* 그래야 눈에 보이는 자리와 잡히는 자리가 같다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 단추 한 번에 도는 각(도) — 15°씩 스물넷이면 한 바퀴. 90°씩이면 맞출 자리가 넷뿐이고,
|
||||
* 5°씩이면 한 바퀴에 일흔두 번이라 성가시다. */
|
||||
const ROTATE_STEP_DEG = 15;
|
||||
|
||||
/** **글자를 눈높이로 세울지**(계획서 0-9 ㉚, 2026-09-12 사용자 지시).
|
||||
*
|
||||
* 그림만 돌고 숫자는 늘 바로 서게 한다 — 180° 로 돌리면 글자가 뒤집혀 안 읽히기 때문이다.
|
||||
* 비용은 라벨 하나에 변환 한 번뿐이라 그림을 다시 그리는 값에 묻힌다.
|
||||
*
|
||||
* ⚠ **되돌리려면 이 값을 `false` 로만 바꾸면 된다** — 그러면 글자도 그림과 함께 돈다
|
||||
* (CAD 도면과 같은 방식). 사용자가 화면을 보고 판단할 수 있게 한 자리에 모아 두었다. */
|
||||
export const UPRIGHT_LABELS = true;
|
||||
|
||||
export interface MapRotationParams {
|
||||
/** 단추가 들어 있는 모달 — `[data-act="rotate-ccw"]`·`rotate-cw` 를 찾는다. */
|
||||
overlay: HTMLElement;
|
||||
/** 지금 캔버스 크기 — 회전축(한가운데)을 잡는 데 쓴다. */
|
||||
size: () => { width: number; height: number };
|
||||
/** 각이 바뀌었다 — 호출부가 다시 그린다. */
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export interface MapRotation {
|
||||
/** 지금 돌린 각(라디안). 그리기가 캔버스 변환에 그대로 쓴다. */
|
||||
radians: () => number;
|
||||
/** 화면에 보이는 자리 → 그리기 좌표(돌리기 전). */
|
||||
unrotate: (px: number, py: number) => [number, number];
|
||||
/** 그리기 좌표 → 화면에 보이는 자리. 떠 있는 패널을 노드 옆에 붙일 때 쓴다. */
|
||||
rerotate: (px: number, py: number) => [number, number];
|
||||
/** 화면에서 민 만큼(dx, dy) → 그림 좌표의 만큼. 팬·휠 확대 보정용. */
|
||||
unrotateDelta: (dx: number, dy: number) => [number, number];
|
||||
/** 글자를 세울 각(라디안) — 그리기가 라벨마다 이만큼 되돌린다. 안 세우면 0. */
|
||||
uprightRad: () => number;
|
||||
}
|
||||
|
||||
export function createMapRotation(params: MapRotationParams): MapRotation {
|
||||
let radians = 0;
|
||||
|
||||
const spin = (px: number, py: number, angle: number): [number, number] => {
|
||||
if (!angle) return [px, py];
|
||||
const { width, height } = params.size();
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const dx = px - cx;
|
||||
const dy = py - cy;
|
||||
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
|
||||
};
|
||||
|
||||
for (const [act, sign] of [
|
||||
["rotate-ccw", -1],
|
||||
["rotate-cw", 1],
|
||||
] as const) {
|
||||
params.overlay.querySelector(`[data-act="${act}"]`)?.addEventListener("click", () => {
|
||||
radians += (sign * ROTATE_STEP_DEG * Math.PI) / 180;
|
||||
params.onChange();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
radians: () => radians,
|
||||
unrotate: (px, py) => spin(px, py, -radians),
|
||||
rerotate: (px, py) => spin(px, py, radians),
|
||||
uprightRad: () => (UPRIGHT_LABELS ? -radians : 0),
|
||||
unrotateDelta: (dx, dy) => {
|
||||
if (!radians) return [dx, dy];
|
||||
const cos = Math.cos(-radians);
|
||||
const sin = Math.sin(-radians);
|
||||
return [dx * cos - dy * sin, dx * sin + dy * cos];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -325,9 +325,15 @@ export function createStructuresSection(
|
||||
const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
|
||||
// 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다.
|
||||
const mustPick = option.required === true && (option.default ?? "") === "";
|
||||
if (mustPick) choices.unshift(["", "— 선택 —"]);
|
||||
// **비워 두는 것이 뜻인 칸**(`empty_means`)도 같다 — 비면 계산 쪽이 기준값으로 도는데
|
||||
// 첫 보기가 골라져 저장되면 기준과 다른 값이 조용히 나간다(2026-09-13:
|
||||
// `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). 기본값이 **있는** 칸은
|
||||
// 그대로 첫 보기를 쓴다 — 2026-08-17 「빈 보기 두지 않음」 지시가 그 자리다.
|
||||
const meansEmpty = (option.empty_means ?? "") !== "" && (option.default ?? "") === "";
|
||||
const keepEmpty = mustPick || meansEmpty;
|
||||
if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]);
|
||||
input = select(choices);
|
||||
input.value = String(preset || (mustPick ? "" : (option.choices[0] ?? "")));
|
||||
input.value = String(preset || (keepEmpty ? "" : (option.choices[0] ?? "")));
|
||||
} else if (option.input === "number") {
|
||||
input = numberInput("0.1", "0");
|
||||
input.value = String(preset ?? "");
|
||||
|
||||
@@ -6,16 +6,21 @@
|
||||
inset: 0;
|
||||
z-index: var(--z-modal, 1000);
|
||||
display: flex;
|
||||
gap: var(--spacing-12);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-12);
|
||||
background: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱).
|
||||
좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 다 가진다. */
|
||||
.b05-routeedit__box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
width: min(1200px, 94vw);
|
||||
max-width: 1200px;
|
||||
height: min(820px, 92vh);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -33,10 +38,15 @@
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b05-routeedit__hint {
|
||||
/* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪).
|
||||
단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */
|
||||
.b05-routeedit__actions {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routeedit__close {
|
||||
@@ -62,27 +72,63 @@
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.b05-routeedit__foot {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b05-routeedit__status {
|
||||
flex: 1 1 auto;
|
||||
/* 지도 위에 얹는 판들 — 아래 정보행을 없애고 여기로 옮겼다(2026-09-12 사용자 지시 ⑩·⑫·⑬).
|
||||
글판은 **클릭을 통과시킨다** — 지도 조작을 가리면 안 된다. */
|
||||
.b05-routeedit__hint,
|
||||
.b05-routeedit__info {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 78%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 조작 설명 — **2열**로 묶는다(사용자 지시 ⑩). 한 줄로 늘어놓으면 창이 좁을 때 접힌다. */
|
||||
.b05-routeedit__hint {
|
||||
top: var(--spacing-12);
|
||||
left: var(--spacing-12);
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 2px var(--spacing-16);
|
||||
max-width: 52%;
|
||||
}
|
||||
|
||||
/* 상태·범례 — 지도 왼쪽 아래(사용자 지시 ⑫). */
|
||||
.b05-routeedit__info {
|
||||
bottom: var(--spacing-12);
|
||||
left: var(--spacing-12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 62%;
|
||||
}
|
||||
|
||||
/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯). 여기만 클릭을 받는다. */
|
||||
.b05-routeedit__spin {
|
||||
position: absolute;
|
||||
top: var(--spacing-12);
|
||||
right: var(--spacing-12);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routeedit__spin .ui-btn {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
font-size: var(--text-body);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.b05-routeedit__legend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__legend i {
|
||||
@@ -101,20 +147,148 @@
|
||||
border-top: 2px solid var(--map-route, #f97316);
|
||||
}
|
||||
|
||||
.b05-routeedit__btn {
|
||||
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. */
|
||||
.b05-routeedit__side {
|
||||
display: flex;
|
||||
flex: none;
|
||||
padding: var(--spacing-8) var(--spacing-16);
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
width: 452px;
|
||||
height: min(820px, 92vh);
|
||||
}
|
||||
|
||||
@media (width < 1500px) {
|
||||
/* 자리가 모자라면 오른쪽 칸을 접는다 — 지도가 먼저다. */
|
||||
.b05-routeedit__side {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.b05-routeedit__cross {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-16, 12px);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-head {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-station {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 횡단 판 — 캔버스 그림이 아니라 **SVG 선**이다(2026-09-12 사용자 지시 ③).
|
||||
휠 = 확대·축소, 가운데 버튼 끌기 = 이동, 더블클릭 = 원복(공용 조각이 맡음). */
|
||||
.b05-routeedit__cross-box {
|
||||
flex: 1 1 auto;
|
||||
min-height: 150px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routeedit__btn.is-primary {
|
||||
border-color: transparent;
|
||||
background: var(--color-primary, #7c3aed);
|
||||
color: #fff;
|
||||
.b05-routeedit__cross-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-svg.is-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-axis {
|
||||
stroke: rgb(148 163 184 / 70%);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-ground {
|
||||
fill: none;
|
||||
stroke: var(--color-text-secondary, #94a3b8);
|
||||
stroke-width: 1.6;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-design {
|
||||
fill: none;
|
||||
stroke: var(--map-route, #f97316);
|
||||
stroke-width: 2.2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-key {
|
||||
fill: var(--color-text-secondary, #94a3b8);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-key.is-design {
|
||||
fill: var(--map-route, #f97316);
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-foot {
|
||||
flex: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ㉓ 거리 재기와 되돌리기 사이 구분선. */
|
||||
.b05-routeedit__divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin: 0 var(--spacing-4, 4px);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창. 닫으면 잰 것이 지워진다. */
|
||||
.b05-routeedit__measure {
|
||||
position: absolute;
|
||||
right: var(--spacing-12);
|
||||
bottom: var(--spacing-12);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-8);
|
||||
max-width: 52%;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid color-mix(in srgb, #22c55e 60%, transparent);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 글이 길면 접힌다 — flex 자식은 기본으로 안 줄어들어 왼쪽으로 넘쳐 잘렸다(2026-09-12). */
|
||||
.b05-routeedit__measure-text {
|
||||
min-width: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.b05-routeedit__measure-close {
|
||||
flex: none;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */
|
||||
@@ -164,6 +338,15 @@
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.b05-routeedit__label-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */
|
||||
.b05-routeedit__lock {
|
||||
padding: 1px 6px;
|
||||
@@ -241,3 +424,11 @@
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* 「거리 재기」가 켜진 동안 — 단추가 눌린 꼴로 남는다(2026-09-12 사용자 지시 ⑥).
|
||||
`is-active` 는 예전에도 붙고 있었으나 꼴이 없어 겉모습이 그대로였다. */
|
||||
.b05-routeedit__actions .ui-btn.is-active {
|
||||
border-color: transparent;
|
||||
background: var(--color-primary, #7c3aed);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -28,9 +28,6 @@ type PendingMap = Record<string, CulvertOptionPatch>;
|
||||
export interface CulvertOptionWriter {
|
||||
/** 측점 옵션을 세션에 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
|
||||
queue: (chainageM: number, patch: CulvertOptionPatch) => void;
|
||||
/** 기준 측점 이동을 예약한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다).
|
||||
* 세부 배수유역 재분할은 저장 시 서버가 관 목록을 다시 받아 처리한다. */
|
||||
queueMove: (fromChainageM: number, toChainageM: number) => void;
|
||||
/** 예약분을 즉시 내보낸다([저장]·[확정]에서만 부른다). */
|
||||
flush: () => Promise<void>;
|
||||
}
|
||||
@@ -62,7 +59,9 @@ function writePending(sessionKey: string | null, pending: PendingMap): void {
|
||||
* 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서
|
||||
* 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다.
|
||||
*/
|
||||
/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m). */
|
||||
/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m).
|
||||
* ⚠ **이제 쓰는 곳은 없다**(2026-09-12 일원화: 관 목록은 B05·B06 이 같은 스냅샷을 쓴다).
|
||||
* 옛 세션에 남아 있던 예약을 저장 때 한 번 흘려보내려고 읽기만 남겨 둔다. */
|
||||
type PendingMoves = Record<string, number>;
|
||||
|
||||
function readMoves(sessionKey: string | null): PendingMoves {
|
||||
@@ -123,17 +122,6 @@ export function createCulvertOptionWriter(
|
||||
pending[keyOf(chainageM)] = { ...(pending[keyOf(chainageM)] ?? {}), ...patch };
|
||||
writePending(key, pending);
|
||||
},
|
||||
queueMove(fromChainageM, toChainageM) {
|
||||
const key = moveKey();
|
||||
if (!key) return;
|
||||
const moves = readMoves(key);
|
||||
// 이미 옮긴 관을 또 옮기면 **원래 자리** 기준으로 최종 위치만 남긴다.
|
||||
const origin =
|
||||
Object.keys(moves).find((entry) => Math.abs(moves[entry] - fromChainageM) < 0.005) ??
|
||||
keyOf(fromChainageM);
|
||||
moves[origin] = toChainageM;
|
||||
window.sessionStorage.setItem(key, JSON.stringify(moves));
|
||||
},
|
||||
async flush() {
|
||||
try {
|
||||
await flushCulvertOptions(projectId(), sessionKey(), moveKey());
|
||||
|
||||
@@ -73,13 +73,13 @@ from B06_Section.B06_Section_Schema import (
|
||||
SectionRegenerateRequest,
|
||||
SectionSummaryResponse,
|
||||
)
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for
|
||||
from common_util.common_util_auth import verify_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
from config.config_system import (
|
||||
EARTHWORK_CONVERSION_FACTORS,
|
||||
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
|
||||
FOREST_ROAD_MIN_WIDTH_M,
|
||||
NATURAL_SPOIL_MIN_GROUND_SLOPE,
|
||||
@@ -144,7 +144,9 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
|
||||
stored_standard_cross_section=stored_standard,
|
||||
rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M,
|
||||
earthwork_conversion=EARTHWORK_CONVERSION_FACTORS,
|
||||
# ⚠ 상수를 직접 들지 않는다 — 프로젝트가 고른 계수가 있으면 화면도 그 값으로
|
||||
# 그려야 서버가 뒤에 다시 셈한 값과 갈리지 않는다(CLAUDE.md 5장).
|
||||
earthwork_conversion=await conversion_factors_for(project_id),
|
||||
haul_equipment_limits=[
|
||||
HaulEquipmentLimit(key=key, max_distance_m=limit)
|
||||
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
|
||||
|
||||
@@ -25,6 +25,7 @@ from fastapi.responses import JSONResponse
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import (
|
||||
BUNDLE,
|
||||
_mass_haul_context,
|
||||
conversion_factors_for,
|
||||
haul_inputs_for,
|
||||
)
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
@@ -63,6 +64,8 @@ async def compute_haul_plan(
|
||||
# 구조물 몫(공제·잔토)을 **넘겨야** 사토가 줄고 는다 — 인자 없이 부르면 늘 `None` 이라
|
||||
# 통로만 있고 값이 안 흐른다(2026-09-09 실측으로 드러난 자리).
|
||||
haul_inputs = await haul_inputs_for(project_id)
|
||||
# 곡선이 쓰는 계수도 프로젝트가 고른 값으로 — 토적표·운반표와 같은 값이어야 한다.
|
||||
factors = await conversion_factors_for(project_id)
|
||||
try:
|
||||
output = await asyncio.to_thread(
|
||||
run_bundle_json,
|
||||
@@ -70,7 +73,7 @@ async def compute_haul_plan(
|
||||
_NPM_SCRIPT,
|
||||
{
|
||||
"haul_plan_for": result,
|
||||
"context": _mass_haul_context(haul_inputs),
|
||||
"context": _mass_haul_context(haul_inputs, factors),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -38,6 +38,10 @@ from B06_Section.B06_Section_Repository import (
|
||||
)
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
from common_util.common_util_project_settings import (
|
||||
earthwork_conversion_factors,
|
||||
quantity_settings,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool, run_with_connection
|
||||
from config.config_system import (
|
||||
@@ -79,7 +83,25 @@ async def haul_inputs_for(project_id: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]:
|
||||
"""이 프로젝트가 쓸 토량환산계수. 못 읽으면 정본 기본값 — 화면은 그대로 선다.
|
||||
|
||||
⚠ 곡선·운반·토적표가 **같은 계수**로 서야 한다. 그래서 상수를 직접 들지 않고 이 함수를
|
||||
거친다(고른 값은 프로젝트 설정 `conversion_factors_override` 에 산다).
|
||||
"""
|
||||
try:
|
||||
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
|
||||
root = resolve_stored_project_path(stored_path)
|
||||
except Exception:
|
||||
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
|
||||
return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
|
||||
return earthwork_conversion_factors(quantity_settings(root))
|
||||
|
||||
|
||||
def _mass_haul_context(
|
||||
haul_inputs: dict[str, Any] | None = None,
|
||||
factors: dict[str, dict[str, float]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
|
||||
|
||||
⚠ 채집석 공제(`collected_stone_deduction_m3`)만 상수가 아니라 **B08 이 내는 값**이다.
|
||||
@@ -93,7 +115,8 @@ def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, A
|
||||
"""
|
||||
inputs = haul_inputs or {}
|
||||
return {
|
||||
"earthwork_conversion": EARTHWORK_CONVERSION_FACTORS,
|
||||
# 프로젝트가 고른 계수가 있으면 그것, 없으면 정본 기본값.
|
||||
"earthwork_conversion": factors or EARTHWORK_CONVERSION_FACTORS,
|
||||
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
|
||||
"haul_equipment_limits": [
|
||||
{"key": key, "max_distance_m": limit}
|
||||
@@ -196,7 +219,9 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
||||
_NPM_SCRIPT,
|
||||
{
|
||||
"detail": detail,
|
||||
"context": _mass_haul_context(haul_inputs),
|
||||
"context": _mass_haul_context(
|
||||
haul_inputs, earthwork_conversion_factors(quantity_settings(project_root))
|
||||
),
|
||||
},
|
||||
)
|
||||
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* 좌측 패널의 표준 횡단면 설정을 이용」. 그래서 **전체 기본값은 좌측 패널**, **이 칸은 그 측점
|
||||
* 하나**만 바꾼다. 표준을 바꿔도 여기서 만진 측점은 그대로다(사용자 값이라 재계산이 살려 둔다).
|
||||
*
|
||||
* 화면에는 **각도(°)**로 보이고 속으로는 경사비(1:n 의 n)로 다닌다 — 소단 폼의
|
||||
* 「안쪽 기울기 (°)」와 같은 말법이다. 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°.
|
||||
* 화면도 속도 **경사비(1:n 의 n)** 로 쓴다(2026-09-12 사용자 지시). 종전에는 화면만 각도(°)로
|
||||
* 바꿔 보여 줬는데, 도면·좌측 [표준 횡단면 설정]·품셈이 모두 1:n 이라 이 칸만 머리로 환산해야
|
||||
* 했다. 참고: 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°.
|
||||
*
|
||||
* ⚠ 경사는 **나르는 값이 아니라 기하 입력**이다. 계산이 끝난 결과에 값만 베껴 붙이면 설계선은
|
||||
* 옛 경사로 그려지고 숫자만 새것이 된다 — 값은 반드시 **계산 전에** 넘어가야 한다
|
||||
@@ -37,13 +38,18 @@ export function degreesToRatio(degrees: number): number {
|
||||
return 1 / Math.tan((clamped * Math.PI) / 180);
|
||||
}
|
||||
|
||||
/** 0.1° 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */
|
||||
function sameDegrees(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < 0.05;
|
||||
/** 경사비를 화면 자릿수(0.01)로 맞춘다 — 사면이 서지 않는 각도(1~89° 밖)는 잘라 받는다. */
|
||||
export function clampRatio(ratio: number): number {
|
||||
return Math.round(degreesToRatio(ratioToDegrees(ratio)) * 100) / 100;
|
||||
}
|
||||
|
||||
/** 0.01 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */
|
||||
function sameRatio(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) < 0.005;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 하단에 서는 「암 절토 68.2° ↺」 칸. 암 경계선 제어와 같은 자리·같은 모양이다.
|
||||
* 카드 하단에 서는 「암 절토 1:0.40 ↺」 칸. 암 경계선 제어와 같은 자리·같은 모양이다.
|
||||
*
|
||||
* 되돌리기(↺)는 **표준 횡단면 설정값**으로 돌려놓는다 — 좌측 패널을 그 뒤에 바꾸면 이 측점도
|
||||
* 따라간다(사용자가 만진 흔적이 지워지므로).
|
||||
@@ -58,30 +64,34 @@ export function buildCutSlopeControl(section: CrossSection, control: CutSlopeCon
|
||||
|
||||
const group = document.createElement("div");
|
||||
group.className = "b06-design__seg-buttons";
|
||||
const ratio = control.ratioFor(section);
|
||||
const standard = control.standardRatioFor(section);
|
||||
const degrees = ratioToDegrees(ratio);
|
||||
const ratio = clampRatio(control.ratioFor(section));
|
||||
const standard = clampRatio(control.standardRatioFor(section));
|
||||
|
||||
// 「1:」은 고정 머리말 — 사용자가 넣는 것은 뒤의 n 하나다.
|
||||
const prefix = document.createElement("span");
|
||||
prefix.className = "b06-design__cutslope-unit";
|
||||
prefix.textContent = "1:";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.className = "b06-design__cutslope-input";
|
||||
input.step = "0.5";
|
||||
input.min = "1";
|
||||
input.max = "89";
|
||||
input.value = degrees.toFixed(1);
|
||||
input.step = "0.05";
|
||||
input.min = "0.02";
|
||||
input.max = "57.29";
|
||||
input.value = ratio.toFixed(2);
|
||||
input.title =
|
||||
`이 측점의 암 절토 경사 — 각도(°)로 넣는다.\n` +
|
||||
`지금 1:${ratio.toFixed(2)} (${degrees.toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` +
|
||||
`이 측점의 암 절토 경사 — 경사비(1:n 의 n)로 넣는다. n 이 작을수록 급하다.\n` +
|
||||
`지금 1:${ratio.toFixed(2)} (${ratioToDegrees(ratio).toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` +
|
||||
`전체를 바꾸려면 좌측 [표준 횡단면 설정]을 쓴다.`;
|
||||
const commit = (): void => {
|
||||
const entered = Number(input.value);
|
||||
if (!Number.isFinite(entered)) {
|
||||
input.value = degrees.toFixed(1);
|
||||
if (!Number.isFinite(entered) || entered <= 0) {
|
||||
input.value = ratio.toFixed(2);
|
||||
return;
|
||||
}
|
||||
// 표준값으로 되돌아온 입력은 사용자 값을 남기지 않는다 — 그래야 표준을 바꿀 때 따라간다.
|
||||
const next = sameDegrees(entered, ratioToDegrees(standard)) ? null : degreesToRatio(entered);
|
||||
control.set(section.chainage_m, next);
|
||||
const next = clampRatio(entered);
|
||||
control.set(section.chainage_m, sameRatio(next, standard) ? null : next);
|
||||
};
|
||||
input.addEventListener("change", commit);
|
||||
input.addEventListener("keydown", (event) => {
|
||||
@@ -93,22 +103,18 @@ export function buildCutSlopeControl(section: CrossSection, control: CutSlopeCon
|
||||
// 카드 클릭(선택·줌)이 입력을 뺏지 않게 한다 — 암 경계선 버튼과 같은 태도.
|
||||
input.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
|
||||
const unit = document.createElement("span");
|
||||
unit.className = "b06-design__cutslope-unit";
|
||||
unit.textContent = "°";
|
||||
|
||||
const reset = document.createElement("button");
|
||||
reset.type = "button";
|
||||
reset.className = "b06-design__rockb-btn is-reset";
|
||||
reset.textContent = "↺";
|
||||
reset.title = `표준값으로 되돌리기 (1:${standard.toFixed(2)} · ${ratioToDegrees(standard).toFixed(1)}°)`;
|
||||
reset.disabled = sameDegrees(degrees, ratioToDegrees(standard));
|
||||
reset.disabled = sameRatio(ratio, standard);
|
||||
reset.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
control.set(section.chainage_m, null);
|
||||
});
|
||||
|
||||
group.append(input, unit, reset);
|
||||
group.append(prefix, input, reset);
|
||||
wrap.append(group);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -721,32 +721,55 @@ export function appendCrossDesignOverlay(
|
||||
}
|
||||
|
||||
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
|
||||
// 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨.
|
||||
// 구분이 안 되는 경계를 표시한다.
|
||||
const edges = design.carriageway_edges;
|
||||
if (edges) {
|
||||
for (const edge of [edges.left, edges.right]) {
|
||||
const cx = x(edge.offset_m);
|
||||
const cy = toDisplayY(edge.elevation_m);
|
||||
const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => {
|
||||
const cx = x(offsetM);
|
||||
const cy = toDisplayY(elevationM);
|
||||
const tick = document.createElementNS(SVG_NS, "line");
|
||||
tick.setAttribute("x1", String(cx));
|
||||
tick.setAttribute("y1", String(cy - 3.6));
|
||||
tick.setAttribute("y1", String(cy - half));
|
||||
tick.setAttribute("x2", String(cx));
|
||||
tick.setAttribute("y2", String(cy + 3.6));
|
||||
tick.setAttribute("class", "b06-chart__carriageway-tick");
|
||||
tick.setAttribute("y2", String(cy + half));
|
||||
tick.setAttribute("class", className);
|
||||
svg.append(tick);
|
||||
};
|
||||
for (const edge of [edges.left, edges.right]) {
|
||||
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6);
|
||||
}
|
||||
// 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어
|
||||
// **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로
|
||||
// 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다.
|
||||
const roadEdges = design.road_edges;
|
||||
if (roadEdges) {
|
||||
for (const edge of [roadEdges.left, roadEdges.right]) {
|
||||
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4);
|
||||
}
|
||||
}
|
||||
// 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다.
|
||||
// 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
|
||||
const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0);
|
||||
const standardWidth = design.carriageway_standard_width_m;
|
||||
const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null;
|
||||
const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null;
|
||||
const label = document.createElementNS(SVG_NS, "text");
|
||||
label.setAttribute("x", String((x(edges.left.offset_m) + x(edges.right.offset_m)) / 2));
|
||||
label.setAttribute("y", String(toDisplayY(edges.left.elevation_m) - 6));
|
||||
label.setAttribute("class", "b06-chart__carriageway-label");
|
||||
label.textContent =
|
||||
const widthText =
|
||||
widened > 0.001 && typeof standardWidth === "number"
|
||||
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})`
|
||||
: `노폭 ${design.carriageway_width_m.toFixed(2)}m`;
|
||||
// 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다.
|
||||
label.textContent =
|
||||
shoulderLeft != null && shoulderRight != null
|
||||
? `${widthText} · 노견 ${
|
||||
Math.abs(shoulderLeft - shoulderRight) < 0.005
|
||||
? `${shoulderLeft.toFixed(2)}m`
|
||||
: `좌 ${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m`
|
||||
}`
|
||||
: widthText;
|
||||
svg.append(label);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,38 @@ export function toeFitHalfWidth(section: CrossSection): number | null {
|
||||
return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M);
|
||||
}
|
||||
|
||||
/**
|
||||
* 절·성토 사면이 원지반과 **실제로 만나는** 좌·우 offset(m). 그 측에서 못 만나면 null.
|
||||
*
|
||||
* `toeFitHalfWidth` 와 같은 교차점을 쓰되 **좌우를 따로** 낸다(2026-09-12 사용자 지시 ②) —
|
||||
* 반폭 하나로 자르면 한쪽만 성토인 측점에서 반대쪽 원지반이 필요보다 넓게 남는다.
|
||||
* **외삽으로 짚은 자리는 안 쓴다**(null) — 지반을 모르는 자리라 거기까지 그으면 없는 지반을
|
||||
* 그리는 셈이 된다.
|
||||
*/
|
||||
export function toeOffsets(section: CrossSection): { left: number | null; right: number | null } {
|
||||
const out: { left: number | null; right: number | null } = { left: null, right: null };
|
||||
const design = section.design;
|
||||
if (!design) return out;
|
||||
const groundAt = groundInterpolator(section.samples);
|
||||
const designAt = designInterpolator(design.design_line);
|
||||
const edges = design.road_edges;
|
||||
if (!groundAt || !designAt || !edges) return out;
|
||||
const lineOffsets = design.design_line.map((point) => point.offset_m);
|
||||
if (lineOffsets.length < 2) return out;
|
||||
const { protectMax, protectMin } = protectedSpan(design, edges);
|
||||
for (const side of ["left", "right"] as const) {
|
||||
const outward = side === "left" ? 1 : -1;
|
||||
const start = side === "left" ? protectMax : protectMin;
|
||||
const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets);
|
||||
const meet = meetOffset(designAt, groundAt, start, limit, outward);
|
||||
// 설계선 끝 밖으로 나간 값은 외삽이다 — 만난 것으로 치지 않는다.
|
||||
if (Math.abs(meet) > Math.abs(limit) + 1e-9) continue;
|
||||
if (Math.abs(designAt(meet) - groundAt(meet)) > MEET_TOLERANCE_M) continue;
|
||||
out[side] = meet;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
|
||||
*
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_View_Zoom.ts
|
||||
* 횡단 카드 그래프의 **확대·축소·팬** 조작구. 카드 렌더러(`_UI_Cross_View.ts`)에서
|
||||
* 700줄 제한으로 분리했다. 동작 규칙(휠은 줌이 아님)은 아래 주석 그대로다.
|
||||
* 횡단 카드 그래프의 **확대·축소·팬** 조작구.
|
||||
*
|
||||
* ⚠ 줌·팬 **본문은 공용 조각**(`A00_Common/b_svg_zoom_pan.ts`)으로 옮겼다
|
||||
* (2026-09-12 사용자 지시 「공용화해줘」) — 계획노선 편집 횡단이 같은 코드를 쓴다.
|
||||
* 여기에는 **B06 것**만 남는다: 카드 여백(`CROSS_PAD`)을 씌운 감싸개, 측점별 배율 기억,
|
||||
* 우측 상단 버튼 묶음. 동작 규칙(휠은 줌이 아님)은 그대로다 — 카드가 수십 장 깔리는
|
||||
* 화면에서 휠을 줌에 묶으면 목록을 훑을 수가 없다(2026-08-02 사용자 확정).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
attachSvgZoomPan,
|
||||
type ContentBounds,
|
||||
type ZoomPanHandle,
|
||||
type ZoomPanState,
|
||||
} from "../A00_Common/b_svg_zoom_pan";
|
||||
import { CROSS_PAD, L } from "./B06_Section_UI_Section_Common";
|
||||
|
||||
/**
|
||||
* 횡단 카드 줌·팬 제어. 반환한 `zoom()`을 그래프 우측 상단 버튼이 부른다.
|
||||
*
|
||||
* **휠은 줌이 아니다**(2026-08-02 사용자 확정). 카드가 수십 장 깔리는 화면에서 휠을 줌에
|
||||
* 묶으면 목록을 훑을 수가 없다. 확대·축소는 버튼, 팬은 가운데 버튼 드래그, 원복은 더블클릭.
|
||||
*/
|
||||
export interface ZoomPanHandle {
|
||||
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
|
||||
zoom: (factor: number) => void;
|
||||
reset: () => void;
|
||||
/** 현재 배율 — 원배율(1)일 때는 버튼이 배율 대신 **표시 반폭**을 조절한다. */
|
||||
scale: () => number;
|
||||
}
|
||||
export type { ContentBounds, ZoomPanHandle, ZoomPanState };
|
||||
|
||||
/**
|
||||
* 카드 표시 반폭 조작(2026-08-23 사용자 지시). 카드 하단 ◀/▶/↺을 없애고 우측 상단
|
||||
@@ -34,28 +33,10 @@ export interface CrossWidthActions {
|
||||
fit: () => void;
|
||||
}
|
||||
|
||||
/** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
|
||||
export interface ZoomPanState {
|
||||
scale: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그려 둔 도형이 실제로 차지하는 범위(원배율 SVG 좌표). 표시 반폭 밖에 있어 잘려 있는
|
||||
* **캐시 보유분**까지 포함한다 — 확대 상태의 팬은 이 범위까지 갈 수 있다
|
||||
* (2026-08-23 사용자 지시).
|
||||
*/
|
||||
export interface ContentBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */
|
||||
export const cardZoomStates = new Map<string, ZoomPanState>();
|
||||
|
||||
/** 카드 SVG 에 줌·팬을 붙인다 — 여백만 씌워 공용 조각에 넘긴다. 부르는 꼴은 그대로다. */
|
||||
export function attachZoomPan(
|
||||
svg: SVGSVGElement,
|
||||
plotLayer: SVGGElement,
|
||||
@@ -68,132 +49,18 @@ export function attachZoomPan(
|
||||
/** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */
|
||||
content?: ContentBounds,
|
||||
): ZoomPanHandle {
|
||||
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
|
||||
const plot = {
|
||||
x: CROSS_PAD.left,
|
||||
y: CROSS_PAD.top,
|
||||
width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1),
|
||||
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1),
|
||||
};
|
||||
const maxScale = 8;
|
||||
let scale = initial?.scale ?? 1;
|
||||
let tx = initial?.tx ?? 0;
|
||||
let ty = initial?.ty ?? 0;
|
||||
const applyTransform = (): void => {
|
||||
plotLayer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
|
||||
onChange?.({ scale, tx, ty });
|
||||
};
|
||||
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
|
||||
// **확대 상태**에서는 한계가 그려진 도형 전체(표시 반폭 밖 캐시 보유분 포함)까지 늘어나,
|
||||
// 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(2026-08-23 사용자 지시).
|
||||
const clampAxis = (
|
||||
value: number,
|
||||
start: number,
|
||||
size: number,
|
||||
from: number,
|
||||
to: number,
|
||||
): number =>
|
||||
Math.min(
|
||||
Math.max(value, Math.min(start + size - scale * to, start - scale * from)),
|
||||
Math.max(start + size - scale * to, start - scale * from),
|
||||
);
|
||||
const clampPan = (): void => {
|
||||
// 도형 범위를 모르면 플롯 영역 자신이 한계다(기존 규칙). 알면 **원배율에서도** 그
|
||||
// 범위까지 열어 둔다 — 캐시 보유분이 표시 반폭보다 넓으면 1배에서도 끌어다 봐야 한다
|
||||
// (2026-08-23 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단).
|
||||
// 캐시가 표시 폭과 같으면 범위가 플롯과 같아져 종전처럼 이동량 0으로 묶인다.
|
||||
const bounds = content
|
||||
? {
|
||||
x: Math.min(content.x, plot.x),
|
||||
y: Math.min(content.y, plot.y),
|
||||
right: Math.max(content.x + content.width, plot.x + plot.width),
|
||||
bottom: Math.max(content.y + content.height, plot.y + plot.height),
|
||||
}
|
||||
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
|
||||
tx = clampAxis(tx, plot.x, plot.width, bounds.x, bounds.right);
|
||||
ty = clampAxis(ty, plot.y, plot.height, bounds.y, bounds.bottom);
|
||||
};
|
||||
|
||||
// 보이는 **플롯 영역의 중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다.
|
||||
const zoom = (factor: number): void => {
|
||||
const next = Math.min(maxScale, Math.max(1, scale * factor));
|
||||
const cx = plot.x + plot.width / 2;
|
||||
const cy = plot.y + plot.height / 2;
|
||||
tx = cx - ((cx - tx) / scale) * next;
|
||||
ty = cy - ((cy - ty) / scale) * next;
|
||||
scale = next;
|
||||
clampPan();
|
||||
applyTransform();
|
||||
};
|
||||
|
||||
let panning = false;
|
||||
let moved = false;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
|
||||
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
|
||||
svg.addEventListener("mousedown", (event) => {
|
||||
if (event.button === 1) event.preventDefault();
|
||||
return attachSvgZoomPan({
|
||||
svg,
|
||||
layer: plotLayer,
|
||||
widthPx,
|
||||
heightPx,
|
||||
pad: CROSS_PAD,
|
||||
initial,
|
||||
onChange,
|
||||
content,
|
||||
// 휠은 카드 목록을 훑는 몫이다 — 줌에 안 묶는다.
|
||||
wheelZoom: false,
|
||||
});
|
||||
svg.addEventListener("auxclick", (event) => {
|
||||
if (event.button === 1) event.preventDefault();
|
||||
});
|
||||
svg.addEventListener("pointerdown", (event) => {
|
||||
// 팬은 **가운데 버튼**만. 좌클릭은 측점 선택·면적 강조 몫이다.
|
||||
if (event.button !== 1) return;
|
||||
event.preventDefault();
|
||||
panning = true;
|
||||
moved = false;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
svg.classList.add("is-panning");
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
});
|
||||
svg.addEventListener("pointermove", (event) => {
|
||||
if (!panning) return;
|
||||
if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true;
|
||||
// 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다).
|
||||
const rect = svg.getBoundingClientRect();
|
||||
tx += ((event.clientX - lastX) / rect.width) * widthPx;
|
||||
ty += ((event.clientY - lastY) / rect.height) * heightPx;
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
clampPan();
|
||||
applyTransform();
|
||||
});
|
||||
const endPan = (event: PointerEvent): void => {
|
||||
if (!panning) return;
|
||||
panning = false;
|
||||
svg.classList.remove("is-panning");
|
||||
try {
|
||||
svg.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
/* 이미 해제됨 */
|
||||
}
|
||||
};
|
||||
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
|
||||
clampPan();
|
||||
applyTransform();
|
||||
}
|
||||
svg.addEventListener("pointerup", endPan);
|
||||
svg.addEventListener("pointercancel", endPan);
|
||||
// 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다.
|
||||
svg.addEventListener("click", (event) => {
|
||||
if (moved) event.stopPropagation();
|
||||
});
|
||||
const currentScale = (): number => scale;
|
||||
const reset = (): void => {
|
||||
scale = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
applyTransform();
|
||||
};
|
||||
// 더블클릭 원복.
|
||||
svg.addEventListener("dblclick", (event) => {
|
||||
event.stopPropagation();
|
||||
reset();
|
||||
});
|
||||
return { zoom, reset, scale: currentScale };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -172,15 +172,9 @@ export function appendWallHatch(
|
||||
((offset - wall.bottomBack.offset) / bottomSpan)
|
||||
: wall.bottomBack.elevation;
|
||||
|
||||
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(사용자 스케치의 가운데 선) —
|
||||
// 상단 변 중간점에서 전면과 나란히 바닥으로 내려온다. 형태와 무관하게 늘 긋는다.
|
||||
const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
|
||||
layer.append(
|
||||
line(
|
||||
[x(wall.topJoint.offset), toDisplayY(wall.topJoint.elevation)],
|
||||
[x(jointBaseOffset), toDisplayY(bottomAtOffset(jointBaseOffset))],
|
||||
),
|
||||
);
|
||||
// 벽을 가로지르던 대각 이음선은 **긋지 않는다**(2026-09-12 사용자: 필요 없음).
|
||||
// 종전에는 상단 변 중간점에서 바닥까지 형태와 무관하게 늘 그어, 돌쌓기·콘크리트 해칭
|
||||
// 위로 선이 하나 더 지나가 도면이 지저분했다.
|
||||
|
||||
clipSeq += 1;
|
||||
const clipId = `b06-revet-clip-${keyId}-${clipSeq}`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session";
|
||||
import { createDesignSync } from "./B06_Section_UI_Page_Design_Sync";
|
||||
import { createGradeEdit } from "./B06_Section_UI_Page_Grade_Edit";
|
||||
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
||||
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
@@ -23,26 +24,20 @@ import {
|
||||
type StandardCrossSection,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
|
||||
import {
|
||||
bermSpansFromStructures,
|
||||
confirmCurrentSections,
|
||||
createCutSlopeStore,
|
||||
createRockBoundaryStore,
|
||||
readBermSpans,
|
||||
saveCurrentSections,
|
||||
writeBermSpans,
|
||||
type SectionPersistContext,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import {
|
||||
readStructurePick,
|
||||
writeStructurePick,
|
||||
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick";
|
||||
import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View";
|
||||
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
|
||||
import { createSectionView } from "./B06_Section_UI_Section_View";
|
||||
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
applyPipeOptionsToCache,
|
||||
@@ -201,8 +196,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
|
||||
applyPipeOptions: (chainageM, patch) =>
|
||||
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
||||
movePipe: (fromChainageM, toChainageM) =>
|
||||
stationControls.queueCulvertMove(fromChainageM, toChainageM),
|
||||
});
|
||||
|
||||
const dockDivider = document.createElement("hr");
|
||||
@@ -218,140 +211,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
|
||||
attachCollapsible(leftForm);
|
||||
|
||||
/**
|
||||
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
|
||||
* (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음.
|
||||
* 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다.
|
||||
*/
|
||||
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
|
||||
if (!projectId || currentRouteId === null || !sectionDetail) return;
|
||||
const target = sectionDetail.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
|
||||
if (target.design) {
|
||||
target.design = {
|
||||
...target.design,
|
||||
ground_type: change.ground_type,
|
||||
section_mode: change.section_mode,
|
||||
ditch_side: change.ditch_side ?? target.design.ditch_side,
|
||||
ditch_type: change.ditch_type,
|
||||
paved: change.paved,
|
||||
two_stage_slope: change.two_stage_slope,
|
||||
ditch_choice: change.ditch_choice,
|
||||
};
|
||||
sectionView.refreshCard(chainageM);
|
||||
}
|
||||
|
||||
// (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고,
|
||||
// [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로
|
||||
// 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다.
|
||||
writeCrossDesignChoice(projectId, currentRouteId, chainageM, {
|
||||
ground_type: change.ground_type,
|
||||
section_mode: change.section_mode,
|
||||
ditch_side: change.ditch_side ?? null,
|
||||
ditch_type: change.ditch_type,
|
||||
paved: change.paved,
|
||||
two_stage_slope: change.two_stage_slope,
|
||||
ditch_choice: change.ditch_choice,
|
||||
});
|
||||
// (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다.
|
||||
await reconcileStaleDesigns({ force: true });
|
||||
}
|
||||
|
||||
/** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
|
||||
function changeFromDesign(chainageM: number): CrossDesignChange | null {
|
||||
const target = sectionDetail?.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
|
||||
);
|
||||
const design = target?.design;
|
||||
if (!design) return null;
|
||||
return {
|
||||
ground_type: design.ground_type,
|
||||
section_mode: design.section_mode,
|
||||
ditch_side: design.ditch_side ?? null,
|
||||
ditch_type: design.ditch_type ?? "standard",
|
||||
paved: design.paved,
|
||||
two_stage_slope: design.two_stage_slope ?? true,
|
||||
ditch_choice: design.ditch_choice ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
|
||||
function recomputeIfRock(chainageM: number): void {
|
||||
const change = changeFromDesign(chainageM);
|
||||
if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로드 시 stale design을 최신 엔진·최신 종단 계획고로 자동 재계산한다(E-1 + N-6).
|
||||
* 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이
|
||||
* 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선
|
||||
* (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API
|
||||
* 호출이 없다.
|
||||
*
|
||||
* 재계산은 측점별 순차 호출이 아니라 **일괄 프리뷰 1회**로 한다(2026-08-04 사용자 확인
|
||||
* — 예전 for-await 루프는 stale 측점 수만큼 왕복하며 카드가 하나씩 바뀌어 "이력 재생"처럼
|
||||
* 보였고 제일 느렸다). 편집 델타는 B05 세션 초안이 있으면 그것을(두 화면 동일 계획선),
|
||||
* 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형·
|
||||
* 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다.
|
||||
*/
|
||||
async function reconcileStaleDesigns(options?: { force?: boolean }): Promise<void> {
|
||||
if (!sectionDetail || !projectId || currentRouteId === null) return;
|
||||
const draft = readAlignmentDraft(currentRouteId);
|
||||
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
|
||||
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
|
||||
//
|
||||
// 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은
|
||||
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
|
||||
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
|
||||
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
|
||||
if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return;
|
||||
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
|
||||
// (CLAUDE.md 5장).
|
||||
try {
|
||||
const alignment = sectionDetail.longitudinal.profile_alignment as
|
||||
| {
|
||||
edits?: {
|
||||
station_offsets?: Record<string, number>;
|
||||
curve_radii?: Record<string, number>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const edits = draft ?? {
|
||||
station_offsets: alignment?.edits?.station_offsets ?? {},
|
||||
curve_radii: alignment?.edits?.curve_radii ?? {},
|
||||
};
|
||||
// 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서
|
||||
// 다른 값이 된다(2026-09-03 사용자 지시로 일원화).
|
||||
const updated = await refreshCrossDesigns({
|
||||
projectId,
|
||||
routeId: currentRouteId,
|
||||
detail: sectionDetail,
|
||||
edits,
|
||||
});
|
||||
for (const chainageM of updated) sectionView.refreshCard(chainageM);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다.
|
||||
*
|
||||
* 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만
|
||||
* 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다.
|
||||
*/
|
||||
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
const next = bermSpansFromStructures(structures);
|
||||
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
|
||||
writeBermSpans(projectId, currentRouteId, next);
|
||||
void reconcileStaleDesigns({ force: true });
|
||||
}
|
||||
// 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
|
||||
const {
|
||||
handleDesignChange,
|
||||
recomputeIfRock,
|
||||
reconcileStaleDesigns,
|
||||
syncBermSpans,
|
||||
applyPanelToAll,
|
||||
} = createDesignSync({
|
||||
projectId,
|
||||
routeId: () => currentRouteId,
|
||||
detail: () => sectionDetail,
|
||||
view: () => sectionView,
|
||||
});
|
||||
|
||||
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
|
||||
async function refreshDetailForStructures(): Promise<void> {
|
||||
@@ -408,28 +280,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
showToast(L("B06_View_Apply_Success"), "success");
|
||||
}
|
||||
|
||||
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
|
||||
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
|
||||
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
|
||||
let applyingAll = false;
|
||||
async function applyPanelToAll(): Promise<void> {
|
||||
if (applyingAll || !sectionDetail || !projectId || currentRouteId === null) return;
|
||||
const targets = sectionDetail.cross_sections.filter((section) => section.design);
|
||||
if (!targets.length) return;
|
||||
// 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장).
|
||||
// 대신 도는 동안 다시 누르는 것만 막는다.
|
||||
applyingAll = true;
|
||||
try {
|
||||
for (const section of targets) {
|
||||
const change = changeFromDesign(section.chainage_m);
|
||||
if (change) await handleDesignChange(section.chainage_m, change);
|
||||
}
|
||||
showToast(L("B06_Std_ApplyAll_Success"), "success");
|
||||
} finally {
|
||||
applyingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리).
|
||||
const rockStore = createRockBoundaryStore({
|
||||
sessionKey: () => stateKey("rockb", projectId, currentRouteId),
|
||||
@@ -483,6 +333,31 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
|
||||
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
|
||||
// 계획선 편집(▲/▼) 제공자는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
|
||||
const gradeEditFor = createGradeEdit({
|
||||
routeId: () => currentRouteId,
|
||||
detail: () => sectionDetail,
|
||||
view: () => sectionView,
|
||||
reconcile: () => reconcileStaleDesigns({ force: true }),
|
||||
});
|
||||
sectionView.setGradeEdit(gradeEditFor);
|
||||
|
||||
// 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한
|
||||
// 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와
|
||||
// 같은 함수를 타므로 목록·폼·알약이 함께 선다.
|
||||
sectionView.setStructureEdit({
|
||||
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
|
||||
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
|
||||
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
|
||||
removeStructure: (structureId) => {
|
||||
structuresPanel.removeStructureById(structureId);
|
||||
},
|
||||
movePipe: (fromChainageM, toChainageM) =>
|
||||
structuresPanel.movePipeTo(fromChainageM, toChainageM),
|
||||
moveStructure: (structureId, toChainageM) => {
|
||||
structuresPanel.moveStructureTo(structureId, toChainageM);
|
||||
},
|
||||
});
|
||||
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
|
||||
const pipeOptionsContext: PipeOptionsContext = {
|
||||
detail: () => sectionDetail,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Design_Sync.ts
|
||||
* 측점 설계 선택 반영·재계산 창구. `_UI_Page` 700줄 제한 대응으로 떼어낸 것이며
|
||||
* 동작은 그대로다(2026-09-13 분리).
|
||||
* - `handleDesignChange` : 버튼 선택 즉시 반영 + 세션 초안 기록 + 재계산
|
||||
* - `recomputeIfRock` : 암 경계·암 절토 경사 변경 후 암 측점만 재계산
|
||||
* - `reconcileStaleDesigns`: 낡은 design 일괄 재계산(B05 와 같은 창구)
|
||||
* - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기
|
||||
* - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영]
|
||||
* ========================================================================== */
|
||||
|
||||
import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
|
||||
import {
|
||||
bermSpansFromStructures,
|
||||
readBermSpans,
|
||||
writeBermSpans,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
|
||||
import { L } from "./B06_Section_UI_Page_Common";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
|
||||
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
|
||||
export interface DesignSyncContext {
|
||||
projectId: string | null;
|
||||
routeId: () => number | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
/** 뷰는 이 모듈보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. */
|
||||
view: () => SectionViewController;
|
||||
}
|
||||
|
||||
export interface DesignSyncController {
|
||||
handleDesignChange: (chainageM: number, change: CrossDesignChange) => Promise<void>;
|
||||
recomputeIfRock: (chainageM: number) => void;
|
||||
reconcileStaleDesigns: (options?: { force?: boolean }) => Promise<void>;
|
||||
syncBermSpans: (structures: ReadonlyArray<StructureInstance>) => void;
|
||||
applyPanelToAll: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
const { projectId } = ctx;
|
||||
|
||||
/**
|
||||
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
|
||||
* (2) 서버에서 단면적을 계산·저장하고 최신 요청이면 그 카드만 다시 갱신한다. 전체 재렌더 없음.
|
||||
* 설정 패널 편집값을 요청에 실어 요청값 → DB 저장 옵션 → config 기본값 우선순위를 지킨다.
|
||||
*/
|
||||
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
|
||||
const sectionDetail = ctx.detail();
|
||||
const currentRouteId = ctx.routeId();
|
||||
if (!projectId || currentRouteId === null || !sectionDetail) return;
|
||||
const target = sectionDetail.cross_sections.find(
|
||||
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
|
||||
if (target.design) {
|
||||
target.design = {
|
||||
...target.design,
|
||||
ground_type: change.ground_type,
|
||||
section_mode: change.section_mode,
|
||||
ditch_side: change.ditch_side ?? target.design.ditch_side,
|
||||
ditch_type: change.ditch_type,
|
||||
paved: change.paved,
|
||||
two_stage_slope: change.two_stage_slope,
|
||||
ditch_choice: change.ditch_choice,
|
||||
};
|
||||
ctx.view().refreshCard(chainageM);
|
||||
}
|
||||
|
||||
// (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고,
|
||||
// [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로
|
||||
// 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다.
|
||||
writeCrossDesignChoice(projectId, currentRouteId, chainageM, {
|
||||
ground_type: change.ground_type,
|
||||
section_mode: change.section_mode,
|
||||
ditch_side: change.ditch_side ?? null,
|
||||
ditch_type: change.ditch_type,
|
||||
paved: change.paved,
|
||||
two_stage_slope: change.two_stage_slope,
|
||||
ditch_choice: change.ditch_choice,
|
||||
});
|
||||
// (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다.
|
||||
await reconcileStaleDesigns({ force: true });
|
||||
}
|
||||
|
||||
/** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
|
||||
function changeFromDesign(chainageM: number): CrossDesignChange | null {
|
||||
const target = ctx
|
||||
.detail()
|
||||
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01);
|
||||
const design = target?.design;
|
||||
if (!design) return null;
|
||||
return {
|
||||
ground_type: design.ground_type,
|
||||
section_mode: design.section_mode,
|
||||
ditch_side: design.ditch_side ?? null,
|
||||
ditch_type: design.ditch_type ?? "standard",
|
||||
paved: design.paved,
|
||||
two_stage_slope: design.two_stage_slope ?? true,
|
||||
ditch_choice: design.ditch_choice ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
|
||||
function recomputeIfRock(chainageM: number): void {
|
||||
const change = changeFromDesign(chainageM);
|
||||
if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
|
||||
}
|
||||
|
||||
/**
|
||||
* 로드 시 stale design을 최신 엔진·최신 종단 계획고로 자동 재계산한다(E-1 + N-6).
|
||||
* 대상: (1) 2단계 경사 필드(`two_stage_slope`)가 없는 옛 암 측점, (2) B05에서 종단이
|
||||
* 변경·확정돼 저장된 계산 기준 계획고(`design.design_elevation_m`)가 현재 계획선
|
||||
* (`design_profiles`) 보간값과 어긋난 측점. 종단이 안 바뀐 측점은 0건이라 불필요한 API
|
||||
* 호출이 없다.
|
||||
*
|
||||
* 재계산은 측점별 순차 호출이 아니라 **일괄 프리뷰 1회**로 한다(2026-08-04 사용자 확인
|
||||
* — 예전 for-await 루프는 stale 측점 수만큼 왕복하며 카드가 하나씩 바뀌어 "이력 재생"처럼
|
||||
* 보였고 제일 느렸다). 편집 델타는 B05 세션 초안이 있으면 그것을(두 화면 동일 계획선),
|
||||
* 없으면 저장분(profile_alignment.edits)을 쓴다. 측점별 사용자 선택값(지반유형·단면유형·
|
||||
* 측구·암 경계)은 서버가 저장분에서 유지하고, 세션에만 있는 암 경계 오프셋은 함께 실어 보낸다.
|
||||
*/
|
||||
async function reconcileStaleDesigns(options?: { force?: boolean }): Promise<void> {
|
||||
const sectionDetail = ctx.detail();
|
||||
const currentRouteId = ctx.routeId();
|
||||
if (!sectionDetail || !projectId || currentRouteId === null) return;
|
||||
const draft = readAlignmentDraft(currentRouteId);
|
||||
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
|
||||
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
|
||||
//
|
||||
// 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은
|
||||
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
|
||||
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
|
||||
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
|
||||
if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return;
|
||||
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
|
||||
// (CLAUDE.md 5장).
|
||||
try {
|
||||
const alignment = sectionDetail.longitudinal.profile_alignment as
|
||||
| {
|
||||
edits?: {
|
||||
station_offsets?: Record<string, number>;
|
||||
curve_radii?: Record<string, number>;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const edits = draft ?? {
|
||||
station_offsets: alignment?.edits?.station_offsets ?? {},
|
||||
curve_radii: alignment?.edits?.curve_radii ?? {},
|
||||
};
|
||||
// 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서
|
||||
// 다른 값이 된다(2026-09-03 사용자 지시로 일원화).
|
||||
const updated = await refreshCrossDesigns({
|
||||
projectId,
|
||||
routeId: currentRouteId,
|
||||
detail: sectionDetail,
|
||||
edits,
|
||||
});
|
||||
// 카드는 한꺼번에 갈아 끼운다 — 측점마다 `refreshCard` 를 부르면 그때마다 종단
|
||||
// 그래프·유토곡선까지 다시 그려 측점 수만큼 화면이 멈췄다(2026-09-12).
|
||||
ctx.view().refreshCards(updated);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다.
|
||||
*
|
||||
* 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만
|
||||
* 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다.
|
||||
*/
|
||||
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
|
||||
const currentRouteId = ctx.routeId();
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
const next = bermSpansFromStructures(structures);
|
||||
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
|
||||
writeBermSpans(projectId, currentRouteId, next);
|
||||
void reconcileStaleDesigns({ force: true });
|
||||
}
|
||||
|
||||
/** 패널 [전체 반영](N-2-1): design 보유 전 측점을 패널 최신값으로 순차 재계산한다.
|
||||
* handleDesignChange가 standardPanel.getValues()를 실어 보내므로 표준단면 수치만
|
||||
* 갱신되고 측점별 버튼 선택값은 보존된다. 순차 await로 동시 API 호출 수를 제한한다. */
|
||||
let applyingAll = false;
|
||||
async function applyPanelToAll(): Promise<void> {
|
||||
const sectionDetail = ctx.detail();
|
||||
if (applyingAll || !sectionDetail || !projectId || ctx.routeId() === null) return;
|
||||
const targets = sectionDetail.cross_sections.filter((section) => section.design);
|
||||
if (!targets.length) return;
|
||||
// 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장).
|
||||
// 대신 도는 동안 다시 누르는 것만 막는다.
|
||||
applyingAll = true;
|
||||
try {
|
||||
for (const section of targets) {
|
||||
const change = changeFromDesign(section.chainage_m);
|
||||
if (change) await handleDesignChange(section.chainage_m, change);
|
||||
}
|
||||
showToast(L("B06_Std_ApplyAll_Success"), "success");
|
||||
} finally {
|
||||
applyingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleDesignChange,
|
||||
recomputeIfRock,
|
||||
reconcileStaleDesigns,
|
||||
syncBermSpans,
|
||||
applyPanelToAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Grade_Edit.ts
|
||||
* 종단 계획선 편집(▲/▼) 제공자. `_UI_Page` 700줄 제한 대응으로 떼어낸 것이며
|
||||
* 동작은 그대로다(2026-09-13 분리).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createProfileEditStore,
|
||||
type ProfileEditStore,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
adjustStation,
|
||||
buildAlignment,
|
||||
toAlignmentBase,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { SectionViewController } from "./B06_Section_UI_Section_View";
|
||||
|
||||
type GradeEditProvider = NonNullable<Parameters<SectionViewController["setGradeEdit"]>[0]>;
|
||||
|
||||
export interface GradeEditContext {
|
||||
routeId: () => number | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
view: () => SectionViewController;
|
||||
/** 전 측점 횡단 재계산 — 손을 뗀 뒤 한 번만 돈다. */
|
||||
reconcile: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선 편집(▲/▼) — B05 와 **같은 세션 초안**에 쌓는다(2026-09-12 사용자: B06 에만
|
||||
* 버튼이 없었다). 누르면 그 자리에서 계획선·전 측점 횡단을 다시 풀고(`reconcile`),
|
||||
* 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장).
|
||||
*/
|
||||
export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider {
|
||||
let gradeStore: ProfileEditStore | null = null;
|
||||
let gradeRouteId: number | null = null;
|
||||
/** ▲▼ 길게 누르기는 초당 10회(`HOLD_INTERVAL_MS`) 들어온다 — 그보다 길게 잡아
|
||||
* 누르는 동안은 선만 움직이고, 손을 뗀 뒤 재계산이 한 번 돈다. */
|
||||
const GRADE_RECONCILE_DEBOUNCE_MS = 120;
|
||||
let gradeReconcileTimer = 0;
|
||||
const scheduleGradeReconcile = (): void => {
|
||||
window.clearTimeout(gradeReconcileTimer);
|
||||
gradeReconcileTimer = window.setTimeout(() => {
|
||||
void ctx.reconcile().then(() => ctx.view().setGradeEdit(gradeEditFor));
|
||||
}, GRADE_RECONCILE_DEBOUNCE_MS);
|
||||
};
|
||||
const gradeEditFor: GradeEditProvider = () => {
|
||||
const detail = ctx.detail();
|
||||
const currentRouteId = ctx.routeId();
|
||||
if (!detail || currentRouteId === null) return null;
|
||||
const stored = readAlignment(detail.longitudinal);
|
||||
if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다.
|
||||
if (!gradeStore || gradeRouteId !== currentRouteId) {
|
||||
gradeRouteId = currentRouteId;
|
||||
gradeStore = createProfileEditStore(currentRouteId, stored.edits, () => undefined);
|
||||
}
|
||||
const store = gradeStore;
|
||||
const base = toAlignmentBase(stored);
|
||||
const alignment = buildAlignment(base, store.edits());
|
||||
return {
|
||||
alignment,
|
||||
stepM: alignment.policy.edit_step_m,
|
||||
onStation: (chainageM, delta) => {
|
||||
store.replace(adjustStation(base, store.edits(), chainageM, delta));
|
||||
// ① 선은 **그 자리에서** 움직인다 — 그래프가 읽는 계획선(`design_profiles`)만
|
||||
// 갈아 끼우고 다시 그린다(재계산을 기다리면 누른 뒤 한참 뒤에 움직였다).
|
||||
const current = ctx.detail();
|
||||
if (current) {
|
||||
current.longitudinal.design_profiles = [
|
||||
toDesignProfile(
|
||||
buildAlignment(base, store.edits()),
|
||||
current.longitudinal.design_profiles?.[0],
|
||||
),
|
||||
];
|
||||
}
|
||||
ctx.view().setGradeEdit(gradeEditFor);
|
||||
// ② 전 측점 횡단 재계산·카드 갱신은 무겁다 — 마지막 한 번만(B05 프리뷰와 같은 규칙).
|
||||
// 편집분은 세션 초안에 있으므로 재계산이 그것을 그대로 읽는다.
|
||||
scheduleGradeReconcile();
|
||||
},
|
||||
};
|
||||
};
|
||||
return gradeEditFor;
|
||||
}
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { invalidateSectionDetail } from "./B06_Section_Section_Store";
|
||||
import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { saveProfileAlignment } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import {
|
||||
clearAlignmentDrafts,
|
||||
readAlignmentDraft,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
|
||||
@@ -418,9 +423,26 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
};
|
||||
}
|
||||
|
||||
/** 계획선 편집 초안이 있으면 종단 정본에 쓰고 초안을 지운다. 없으면 아무 일도 하지 않는다. */
|
||||
async function flushAlignmentDraft(projectId: string, routeId: number | null): Promise<void> {
|
||||
if (routeId === null) return;
|
||||
const draft = readAlignmentDraft(routeId);
|
||||
if (!draft) return;
|
||||
await saveProfileAlignment(projectId, routeId, draft);
|
||||
clearAlignmentDrafts();
|
||||
}
|
||||
|
||||
/** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */
|
||||
async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise<void> {
|
||||
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
||||
// ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도
|
||||
// 결과가 같아야 한다). 관 목록은 B05 가 **전체 스냅샷**으로, B06 이 **바뀐 것만**(추가·
|
||||
// 삭제·이동·구간값) 담으므로, 스냅샷을 먼저 얹고 그 위에 델타를 적용해야 한다. 반대로
|
||||
// 하면 스냅샷이 B06 편집을 통째로 덮는다.
|
||||
await flushPendingPipes(projectId).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`배수관 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
// 조정창 구간값·추가·삭제·이동은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
|
||||
await ctx.flushCulvertOptions();
|
||||
// B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만
|
||||
@@ -431,11 +453,12 @@ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string):
|
||||
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
|
||||
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
|
||||
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
|
||||
// B05 배수유역도에서 고친 관 목록(추가·이동·삭제)도 여기서 정본에 남긴다 — 예전에는
|
||||
// B05 [임시저장]에만 실려, B06 에서 저장하면 그 편집이 사라졌다(2026-09-06 대응표).
|
||||
await flushPendingPipes(projectId).catch((error) => {
|
||||
// 계획선 편집(▲/▼)은 세션 초안에만 있다 — B06 에서 고쳤든 B05 에서 고쳤든 여기서
|
||||
// 종단 정본으로 내보낸다(2026-09-12). 종전에는 B06 이 초안을 **읽기만** 해서, B06 에서
|
||||
// 저장하면 계획선 편집이 다음 진입 때 사라졌다.
|
||||
await flushAlignmentDraft(projectId, ctx.routeId()).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
showToast(`배수관 저장에 실패했습니다.${detail}`, "error");
|
||||
showToast(`계획선 저장에 실패했습니다.${detail}`, "error");
|
||||
});
|
||||
await flushPendingStructures(projectId).catch((error) => {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
|
||||
@@ -94,8 +94,6 @@ export interface StationControls {
|
||||
flushCulvertOptions: () => Promise<void>;
|
||||
/** 관 옵션 조각을 예약한다 — 좌측 폼의 [수정]도 이 경로로 정본에 간다(2026-08-29). */
|
||||
queueCulvertOptions: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
/** 기준 측점 이동을 예약한다 — B06 구조물 배치 폼의 측점 칸이 쓴다(2026-08-29). */
|
||||
queueCulvertMove: (fromChainageM: number, toChainageM: number) => void;
|
||||
load: () => void;
|
||||
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
|
||||
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
|
||||
@@ -617,8 +615,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
},
|
||||
flushCulvertOptions: () => culvertOptions.flush(),
|
||||
queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch),
|
||||
queueCulvertMove: (fromChainageM, toChainageM) =>
|
||||
culvertOptions.queueMove(fromChainageM, toChainageM),
|
||||
load: () => {
|
||||
loadStationWidths();
|
||||
loadRevetShifts();
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
readStructurePick,
|
||||
@@ -39,8 +40,13 @@ import {
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
|
||||
const PIPE_ADD_GUIDE = "계곡 통과 시설의 추가·삭제는 B05(종단) 화면에서 합니다.";
|
||||
const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요.";
|
||||
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
|
||||
/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */
|
||||
const PIPE_ADD_NOTICE =
|
||||
"계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
const PIPE_REMOVE_NOTICE =
|
||||
"계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
|
||||
/** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */
|
||||
const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다.";
|
||||
const PIPE_MOVE_NOTICE =
|
||||
@@ -72,9 +78,6 @@ export interface B06StructuresPanelDeps {
|
||||
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
|
||||
* 조정창과 같은 흐름이라 폼 조작도 도면에 바로 보인다(2026-08-29 사용자 보고). */
|
||||
applyPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
/** 기준 측점 이동을 예약한다 — 세부 배수유역 재분할은 [저장]·[확정] 때 서버가
|
||||
* 관 목록을 다시 받아 처리한다(2026-08-29 사용자: B06에서도 옮길 수 있어야 한다). */
|
||||
movePipe?: (fromChainageM: number, toChainageM: number) => void;
|
||||
/** 구조물 목록이 바뀌었다 — 벽(C군)은 횡단 제원으로 얹혀 **면적까지 달라지므로**
|
||||
* 화면이 횡단을 다시 받아 그려야 한다(2026-09-06 사용자 확정). */
|
||||
onStructuresChanged?: () => void;
|
||||
@@ -97,6 +100,13 @@ export interface B06StructuresPanel {
|
||||
showAtChainage: (chainageM: number | null) => void;
|
||||
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
|
||||
currentChainage: () => number | null;
|
||||
/** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */
|
||||
addStructureAt: (chainageM: number, typeId: string) => void;
|
||||
removeStructureById: (structureId: string) => boolean;
|
||||
addPipeAt: (chainageM: number) => void;
|
||||
removePipeAt: (chainageM: number) => void;
|
||||
movePipeTo: (fromChainageM: number, toChainageM: number) => void;
|
||||
moveStructureTo: (structureId: string, toChainageM: number) => boolean;
|
||||
/** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */
|
||||
facility: {
|
||||
setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void;
|
||||
@@ -248,29 +258,39 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
},
|
||||
getInterval: deps.stationInterval,
|
||||
onReveal: () => deps.reveal?.(),
|
||||
onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"),
|
||||
// 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 →
|
||||
// [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다.
|
||||
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
|
||||
onPipeAdd: (chainageM, attributes) => {
|
||||
if (!deps.projectId) {
|
||||
showToast(PIPE_ADD_GUIDE, "error");
|
||||
return;
|
||||
}
|
||||
const at = Number(chainageM.toFixed(2));
|
||||
if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) {
|
||||
showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error");
|
||||
return;
|
||||
}
|
||||
pipeFacilities.push({
|
||||
chainage_m: at,
|
||||
facility: attributes.facility ?? "pipe",
|
||||
source: "user",
|
||||
options: attributes.options ?? {},
|
||||
});
|
||||
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
||||
currentChainageM = at;
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.();
|
||||
showToast(PIPE_ADD_NOTICE, "success");
|
||||
},
|
||||
// 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다
|
||||
// (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다.
|
||||
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
|
||||
// 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는
|
||||
// 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다
|
||||
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
|
||||
const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M;
|
||||
if (moved) {
|
||||
if (deps.movePipe) {
|
||||
deps.movePipe(fromChainageM, toChainageM);
|
||||
const hit = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (hit) hit.chainage_m = toChainageM;
|
||||
currentChainageM = toChainageM;
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
pushMarks();
|
||||
showToast(PIPE_MOVE_NOTICE, "success");
|
||||
} else {
|
||||
showToast(PIPE_MOVE_GUIDE, "error");
|
||||
}
|
||||
}
|
||||
if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M)
|
||||
movePipeTo(fromChainageM, toChainageM);
|
||||
const patch = attributes.options;
|
||||
if (!patch || !Object.keys(patch).length) return;
|
||||
if (!deps.queuePipeOptions) {
|
||||
@@ -287,9 +307,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (hit) hit.options = { ...(hit.options ?? {}), ...patch };
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
},
|
||||
onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"),
|
||||
onPipeRemove: (chainageM) => removePipeAt(chainageM),
|
||||
onPipeSelect: (chainageM) => {
|
||||
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
|
||||
writeStructurePick(deps.projectId, chainageM);
|
||||
@@ -339,7 +360,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
structures = readPendingStructures(projectId) ?? stored.structures;
|
||||
section.setStructures(structures);
|
||||
const detail = deps.detail?.() ?? null;
|
||||
pipeFacilities = pipeResponse.pipe_points.map((pipe) => ({
|
||||
// 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며,
|
||||
// 이래야 B05 에서 넣고 B06 으로 넘어와도 같은 목록이 보인다(2026-09-12 사용자).
|
||||
const pendingPipes = readPendingPipes(projectId);
|
||||
pipeFacilities = (pendingPipes ?? pipeResponse.pipe_points).map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
facility: pipe.facility ?? "pipe",
|
||||
start_m: pipe.start_m,
|
||||
@@ -400,11 +424,77 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관 목록을 **미저장 스냅샷**으로 남긴다 — B05 가 쓰는 바로 그 세션이다(2026-09-12
|
||||
* 사용자: 담는 꼴도 한 벌이어야 한다). 종전에는 B06 만 「바뀐 것만」 따로 담아
|
||||
* 두 페이지가 서로 다른 자리를 보고 저장 순서에 따라 결과가 갈렸다.
|
||||
*/
|
||||
function persistPipes(): void {
|
||||
if (!deps.projectId) return;
|
||||
writePendingPipes(
|
||||
deps.projectId,
|
||||
pipeFacilities.map((pipe) => ({
|
||||
chainage_m: pipe.chainage_m,
|
||||
source: pipe.source ?? "user",
|
||||
facility: pipe.facility,
|
||||
...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}),
|
||||
...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}),
|
||||
...(pipe.options ? { options: pipe.options } : {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */
|
||||
function movePipeTo(fromChainageM: number, toChainageM: number): void {
|
||||
if (!deps.projectId) {
|
||||
showToast(PIPE_MOVE_GUIDE, "error");
|
||||
return;
|
||||
}
|
||||
const to = Number(toChainageM.toFixed(2));
|
||||
const hit = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (hit) hit.chainage_m = to;
|
||||
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
|
||||
currentChainageM = to;
|
||||
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
pushMarks();
|
||||
showToast(PIPE_MOVE_NOTICE, "success");
|
||||
}
|
||||
|
||||
/** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */
|
||||
function removePipeAt(chainageM: number): void {
|
||||
if (!deps.projectId) {
|
||||
showToast(PIPE_ADD_GUIDE, "error");
|
||||
return;
|
||||
}
|
||||
const at = Number(chainageM.toFixed(2));
|
||||
pipeFacilities = pipeFacilities.filter(
|
||||
(entry) => Math.abs(entry.chainage_m - at) >= PIPE_MATCH_M,
|
||||
);
|
||||
if (currentChainageM !== null && Math.abs(currentChainageM - at) < PIPE_MATCH_M)
|
||||
currentChainageM = null;
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.();
|
||||
showToast(PIPE_REMOVE_NOTICE, "success");
|
||||
}
|
||||
|
||||
return {
|
||||
root: section.root,
|
||||
listRoot: section.listRoot,
|
||||
load,
|
||||
currentChainage: () => currentChainageM,
|
||||
// 종단 우클릭 → 좌측 폼·목록과 같은 함수. 폼이 열리고 목록·알약도 함께 선다.
|
||||
addStructureAt: (chainageM, typeId) => section.addAt(chainageM, typeId),
|
||||
removeStructureById: (structureId) => section.removeById(structureId),
|
||||
addPipeAt: (chainageM) => section.addAt(chainageM, "pipe"),
|
||||
removePipeAt: (chainageM) => removePipeAt(chainageM),
|
||||
movePipeTo: (fromChainageM, toChainageM) => movePipeTo(fromChainageM, toChainageM),
|
||||
moveStructureTo: (structureId, toChainageM) => section.moveById(structureId, toChainageM),
|
||||
facility: {
|
||||
setInletStructure: (value) => section.facility.setInletStructure(value),
|
||||
onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler),
|
||||
|
||||
@@ -17,8 +17,6 @@ import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
// 가로 스크롤 고정 Y축 — B05와 같은 오버레이를 쓴다(정의처: B05 MassHaul 모듈 + 그 CSS).
|
||||
import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||
import type {
|
||||
CrossSection,
|
||||
EarthworkConversion,
|
||||
@@ -45,33 +43,24 @@ import {
|
||||
effectiveCardHalfWidth,
|
||||
} from "./B06_Section_UI_Cross_View_Metrics";
|
||||
import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
|
||||
import { LONG_PAD } from "./B06_Section_UI_Section_Common";
|
||||
import { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
|
||||
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
|
||||
import {
|
||||
buildStructureLane,
|
||||
STRUCTURE_LANE_HEIGHT_PX,
|
||||
} from "../B05_Profile/B05_Profile_UI_Structures_Marks";
|
||||
attachWindowScroll,
|
||||
createElevationHold,
|
||||
drawLongitudinalPanel,
|
||||
type LongitudinalPanelInput,
|
||||
} from "./B06_Section_UI_Section_View_Draw";
|
||||
import {
|
||||
structureAnchorM,
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal";
|
||||
import {
|
||||
buildLongitudinalChart,
|
||||
visibleChainageRange,
|
||||
visibleElevationRange,
|
||||
} from "./B06_Section_UI_Section_View_Chart";
|
||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
|
||||
import { badgeValuesFrom, createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import {
|
||||
applyElevationWindow,
|
||||
needsFullRedraw,
|
||||
Y_AXIS_WINDOW_CLASS,
|
||||
type ElevationWindowResult,
|
||||
} from "@util/common_util_chart_ywindow";
|
||||
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import { type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
import {
|
||||
BASE_PANEL_HEIGHT,
|
||||
chartHeights,
|
||||
@@ -118,6 +107,8 @@ export interface SectionViewController {
|
||||
) => void;
|
||||
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
|
||||
refreshCard: (chainageM: number) => void;
|
||||
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
|
||||
refreshCards: (chainages: ReadonlyArray<number>) => void;
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
|
||||
focusStation: (stationId: string) => void;
|
||||
@@ -131,6 +122,10 @@ export interface SectionViewController {
|
||||
structures: ReadonlyArray<StructureInstance>,
|
||||
types: ReadonlyArray<StructureType>,
|
||||
) => void;
|
||||
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
|
||||
setStructureEdit: (edit: SectionStructureEdit | null) => void;
|
||||
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
|
||||
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
@@ -163,6 +158,8 @@ export function createSectionView(
|
||||
let currentNaturalSpoilSlope: number | undefined;
|
||||
let markStructures: ReadonlyArray<StructureInstance> = [];
|
||||
let markTypes: ReadonlyArray<StructureType> = [];
|
||||
let structureEdit: SectionStructureEdit | null = null;
|
||||
let gradeEdit: (() => LongitudinalPanelInput["grade"]) | null = null;
|
||||
let renderWidth = 0;
|
||||
let resizeTimer = 0;
|
||||
let panelResizeTimer = 0;
|
||||
@@ -504,94 +501,25 @@ export function createSectionView(
|
||||
const laneHeight = markStructures.length && markTypes.length ? STRUCTURE_LANE_HEIGHT_PX : 0;
|
||||
const heights = chartHeights(Math.max(lastChartAvailable - laneHeight, MIN_LONG_HEIGHT));
|
||||
const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval);
|
||||
const chartWidth = Math.max(renderWidth, minWidth);
|
||||
const chart = buildLongitudinalChart({
|
||||
updateChartWindow = drawLongitudinalPanel({
|
||||
detail,
|
||||
chartWrap,
|
||||
renderWidth: Math.max(renderWidth, minWidth),
|
||||
chartHeight: heights.long,
|
||||
keepScrollLeft,
|
||||
selectedStationId,
|
||||
exaggeration: currentExaggeration,
|
||||
selectStation: (stationId) => selectStation(stationId, true),
|
||||
stationInterval: cachedStationInterval,
|
||||
chartWidth,
|
||||
chartHeight: heights.long,
|
||||
minWidth,
|
||||
scrollLeft: keepScrollLeft,
|
||||
viewportWidth: chartWrap.clientWidth || chartWidth,
|
||||
structures: markStructures,
|
||||
types: markTypes,
|
||||
edit: structureEdit,
|
||||
conversion: currentConversion,
|
||||
naturalSpoilSlope: currentNaturalSpoilSlope,
|
||||
selectStation: (stationId) => selectStation(stationId, true),
|
||||
setMassBadge: (values) => massBadge.set(values),
|
||||
grade: gradeEdit?.() ?? null,
|
||||
holdRange,
|
||||
});
|
||||
const longAxis = chart.axis;
|
||||
const nodes: Element[] = [chart.node];
|
||||
// 구조물 알약 레인 — B05 종단과 **같은 부품**(`buildStructureLane`)을 그대로 쓴다.
|
||||
// 배수관·세월교도 여기 같은 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일).
|
||||
if (markStructures.length && markTypes.length) {
|
||||
nodes.push(
|
||||
buildStructureLane({
|
||||
structures: markStructures,
|
||||
types: markTypes,
|
||||
x: chart.toX,
|
||||
chainageAt: chart.toChainage,
|
||||
maxChainageM: chart.maxChainageM,
|
||||
widthPx: chartWidth,
|
||||
axisWidthPx: LONG_PAD.left,
|
||||
stationIntervalM: cachedStationInterval,
|
||||
selectedId: null,
|
||||
// B06 에서 알약을 누르면 그 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
|
||||
onSelect: (structureId) => {
|
||||
if (!structureId) return;
|
||||
const hit = markStructures.find((entry) => entry.structure_id === structureId);
|
||||
if (!hit) return;
|
||||
const at = structureAnchorM(hit);
|
||||
// 그 자리에 가장 가까운 측점 카드를 고른다 — 좌측 목록 클릭과 같은 규칙.
|
||||
let best: { id: string; gap: number } | null = null;
|
||||
for (const section of currentDetail?.cross_sections ?? []) {
|
||||
const gap = Math.abs(section.chainage_m - at);
|
||||
if (!best || gap < best.gap) best = { id: section.station_id, gap };
|
||||
}
|
||||
if (best) selectStation(best.id, true);
|
||||
},
|
||||
// 알약 끌기는 B05 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다.
|
||||
onMove: () => undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
chartWrap.replaceChildren(...nodes);
|
||||
// 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이
|
||||
// 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서
|
||||
// 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단).
|
||||
if (currentConversion) {
|
||||
const series = computeMassHaulSeries(
|
||||
detail.longitudinal,
|
||||
detail.cross_sections,
|
||||
currentConversion,
|
||||
currentNaturalSpoilSlope,
|
||||
);
|
||||
const cross = series.find((entry) => entry.basis === "cross") ?? series[0];
|
||||
massBadge.set(cross ? badgeValuesFrom(cross.result) : null);
|
||||
} else {
|
||||
massBadge.set(null);
|
||||
}
|
||||
// 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
|
||||
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다).
|
||||
if (longAxis) {
|
||||
const overlay = buildStickyYAxis(longAxis, heights.long);
|
||||
// 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분).
|
||||
overlay.classList.add(Y_AXIS_WINDOW_CLASS);
|
||||
chartWrap.prepend(overlay);
|
||||
}
|
||||
|
||||
// 세로 창 갱신기 — 스크롤마다 종단은 변환으로, 유토곡선은 곡선만 다시 그려 따라온다.
|
||||
updateChartWindow = () => {
|
||||
const { fromM, toM } = visibleChainageRange(
|
||||
chart.toChainage,
|
||||
chart.maxChainageM,
|
||||
chartWrap.scrollLeft,
|
||||
chartWrap.clientWidth || chartWidth,
|
||||
);
|
||||
return applyElevationWindow(
|
||||
chartWrap,
|
||||
visibleElevationRange(detail, fromM, toM) ?? undefined,
|
||||
);
|
||||
};
|
||||
chartWrap.scrollLeft = keepScrollLeft;
|
||||
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
|
||||
syncScrollMargin();
|
||||
|
||||
@@ -608,22 +536,8 @@ export function createSectionView(
|
||||
}
|
||||
}
|
||||
|
||||
/** 상단 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||
const SCROLL_SETTLE_MS = 80;
|
||||
let settledScrollLeft = 0;
|
||||
let scrollSettleTimer = 0;
|
||||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정, B05와 같은 규칙) —
|
||||
// 스크롤마다 겹 하나의 변환만 갈아 끼우므로 실시간으로 따라온다.
|
||||
chartWrap.addEventListener("scroll", () => {
|
||||
const fitted = updateChartWindow?.() ?? null;
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = chartWrap.scrollLeft;
|
||||
drawPanel();
|
||||
}, SCROLL_SETTLE_MS);
|
||||
});
|
||||
attachWindowScroll(chartWrap, () => updateChartWindow, drawPanel);
|
||||
const holdRange = createElevationHold(chartWrap, drawPanel);
|
||||
|
||||
const draw = (): void => {
|
||||
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
|
||||
@@ -689,18 +603,35 @@ export function createSectionView(
|
||||
chartWrap.scrollLeft = keepScrollLeft;
|
||||
};
|
||||
|
||||
const refreshCard = (chainageM: number): void => {
|
||||
if (!currentDetail) return;
|
||||
/** 카드 한 장만 갈아 끼운다(상단 패널은 안 건드린다). 못 찾으면 false. */
|
||||
const rebuildCard = (chainageM: number): boolean => {
|
||||
if (!currentDetail) return false;
|
||||
const section = currentDetail.cross_sections.find(
|
||||
(candidate) => Math.abs(candidate.chainage_m - chainageM) < 0.01,
|
||||
);
|
||||
if (!section) return;
|
||||
if (!section) return false;
|
||||
const existing = document.getElementById(`cross-${section.station_id}`);
|
||||
// 단건 갱신은 draw에서 정해둔 행 높이를 재사용해 같은 행 카드와 높이를 유지한다.
|
||||
if (existing)
|
||||
existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(section.station_id)));
|
||||
return true;
|
||||
};
|
||||
|
||||
const refreshCard = (chainageM: number): void => {
|
||||
// 단면적이 바뀌면 유토곡선도 함께 흔들리므로 상단 패널만 다시 그린다(카드 전체 재렌더 없음).
|
||||
drawPanel();
|
||||
if (rebuildCard(chainageM)) drawPanel();
|
||||
};
|
||||
|
||||
/**
|
||||
* 여러 측점을 한 번에 갈아 끼운다 — **상단 패널은 끝에 한 번만** 다시 그린다.
|
||||
*
|
||||
* 측점마다 `refreshCard` 를 부르면 측점 수만큼 종단 그래프·유토곡선을 다시 그려,
|
||||
* 계획선 ▲▼ 한 번에 화면이 몇 백 ms 씩 멈췄다(2026-09-12 B05 대비 느림 원인).
|
||||
*/
|
||||
const refreshCards = (chainages: ReadonlyArray<number>): void => {
|
||||
let touched = false;
|
||||
for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true;
|
||||
if (touched) drawPanel();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
@@ -746,6 +677,7 @@ export function createSectionView(
|
||||
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root));
|
||||
},
|
||||
refreshCard,
|
||||
refreshCards,
|
||||
focusStation(stationId) {
|
||||
if (selectedStationId !== stationId) selectStation(stationId, true);
|
||||
else revealCard(stationId, "smooth");
|
||||
@@ -753,6 +685,14 @@ export function createSectionView(
|
||||
setStationSelectListener(listener) {
|
||||
stationSelectListener = listener;
|
||||
},
|
||||
setStructureEdit(edit) {
|
||||
structureEdit = edit;
|
||||
drawPanel();
|
||||
},
|
||||
setGradeEdit(provider) {
|
||||
gradeEdit = provider;
|
||||
drawPanel();
|
||||
},
|
||||
setStructureMarks(structures, types) {
|
||||
markStructures = structures;
|
||||
markTypes = types;
|
||||
|
||||
@@ -30,6 +30,15 @@ export interface LongitudinalChartInput {
|
||||
/** 지금 보이는 구간을 정하는 값 — 가로 스크롤 위치와 컨테이너 안쪽 폭. */
|
||||
scrollLeft: number;
|
||||
viewportWidth: number;
|
||||
/** 구조물(비정규) 측점선을 끌어 옮겼다. 안 넘기면 그 선은 못 잡는다. */
|
||||
onDragStation?: (stationId: string, toChainageM: number) => void;
|
||||
/** X축·측점 라벨을 바닥에서 이만큼(px) 올린다 — 계획고 편집 ▼ 버튼과 겹치지 않게. */
|
||||
bottomInsetPx?: number;
|
||||
/** 세로 창을 가로채는 자리 — 계획고를 ▲▼ 로 만지는 동안 창을 **고정**하는 데 쓴다.
|
||||
* 안 넘기면 늘 보이는 구간에 맞춘다(종전 동작). */
|
||||
holdRange?: (
|
||||
next: { min: number; max: number } | null,
|
||||
) => { min: number; max: number } | undefined;
|
||||
}
|
||||
|
||||
export interface LongitudinalChartResult {
|
||||
@@ -110,10 +119,13 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi
|
||||
axis = next;
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
// 구조물(비정규) 측점선 끌어 옮기기 — B05 와 같은 조작이다(2026-09-12 일원화).
|
||||
input.onDragStation,
|
||||
input.bottomInsetPx ?? 0,
|
||||
0,
|
||||
0,
|
||||
visibleElevationRange(detail, fromM, toM) ?? undefined,
|
||||
input.holdRange
|
||||
? input.holdRange(visibleElevationRange(detail, fromM, toM))
|
||||
: (visibleElevationRange(detail, fromM, toM) ?? undefined),
|
||||
),
|
||||
);
|
||||
return { node, axis, toChainage, toX, maxChainageM, viewFromM: fromM, viewToM: toM };
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Section_View_Draw.ts
|
||||
* B06 상단 패널의 **종단 그래프 한 벌**을 세운다 — 그래프·구조물 알약 레인·우클릭 메뉴·
|
||||
* 누가토량 배지·고정 Y축, 그리고 가로 스크롤에 따라붙는 세로 창 갱신기까지.
|
||||
*
|
||||
* 왜 갈랐나 — 종단 화면 본체(`_Section_View`)가 700줄을 넘겨(CLAUDE.md 4장) 기능별로
|
||||
* 나눠야 했다. 이 파일은 **그리는 몫만** 맡고, 높이 재기·되먹임 보정·선택 상태는 본체가
|
||||
* 그대로 쥔다. 넘겨받는 값은 전부 인자라 여기에는 화면 상태가 남지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EarthworkConversion, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
|
||||
import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
|
||||
import { badgeValuesFrom } from "@util/common_util_mass_haul_badge";
|
||||
import {
|
||||
applyElevationWindow,
|
||||
needsFullRedraw,
|
||||
Y_AXIS_WINDOW_CLASS,
|
||||
type ElevationWindowResult,
|
||||
} from "@util/common_util_chart_ywindow";
|
||||
import { LONG_PAD } from "./B06_Section_UI_Section_Common";
|
||||
import {
|
||||
buildLongitudinalChart,
|
||||
visibleChainageRange,
|
||||
visibleElevationRange,
|
||||
} from "./B06_Section_UI_Section_View_Chart";
|
||||
import {
|
||||
mountSectionStructureMenu,
|
||||
moveMarkById,
|
||||
type SectionStructureEdit,
|
||||
} from "./B06_Section_UI_Section_View_Menu";
|
||||
import { createEditOverlay } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
/** 그래프 한 벌을 세우는 데 필요한 값 — 본체가 재고 고른 것을 그대로 넘긴다. */
|
||||
export interface LongitudinalPanelInput {
|
||||
detail: SectionDetailResponse;
|
||||
/** 그래프가 들어앉는 칸. 내용은 이 함수가 통째로 갈아 끼운다. */
|
||||
chartWrap: HTMLElement;
|
||||
/** 본체가 잰 그릴 폭·높이. */
|
||||
renderWidth: number;
|
||||
chartHeight: number;
|
||||
/** 갈아 끼우기 전 가로 스크롤 자리 — 되돌려 놓는다. */
|
||||
keepScrollLeft: number;
|
||||
selectedStationId: string | null;
|
||||
exaggeration: number;
|
||||
stationInterval: number;
|
||||
structures: ReadonlyArray<StructureInstance>;
|
||||
types: ReadonlyArray<StructureType>;
|
||||
/** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭·끌기를 안 붙인다). */
|
||||
edit: SectionStructureEdit | null;
|
||||
conversion?: EarthworkConversion;
|
||||
naturalSpoilSlope?: number;
|
||||
selectStation: (stationId: string) => void;
|
||||
/** 세로 창 가로채기 — 계획고를 만지는 동안 창을 고정한다(B05 와 같은 장치). */
|
||||
holdRange?: (
|
||||
next: { min: number; max: number } | null,
|
||||
) => { min: number; max: number } | undefined;
|
||||
/** 계획선 편집 — 넘기면 종단 그래프 위에 ▲/▼ 버튼층이 선다(B05 와 같은 부품).
|
||||
* 안 넘기면 버튼이 없고 그래프만 보인다(옛 저장분처럼 선형이 없는 노선). */
|
||||
grade?: {
|
||||
alignment: ProfileAlignment;
|
||||
/** 한 번 누를 때 오르내리는 양(m) — 선형 정책값. */
|
||||
stepM: number;
|
||||
onStation: (chainageM: number, delta: number) => void;
|
||||
} | null;
|
||||
/** 좌측 상단 누가토량 배지 — 값이 없으면 null 로 지운다. */
|
||||
setMassBadge: (values: ReturnType<typeof badgeValuesFrom> | null) => void;
|
||||
}
|
||||
|
||||
/** 알약 레인이 설 높이(px) — 본체가 그릴 높이를 셈할 때 같은 값을 써야 한다. */
|
||||
export { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
|
||||
|
||||
/**
|
||||
* 종단 그래프 한 벌을 세우고 **세로 창 갱신기**를 돌려준다.
|
||||
* 갱신기는 가로 스크롤마다 불러 보이는 구간에 세로를 맞추는 데 쓴다.
|
||||
*/
|
||||
export function drawLongitudinalPanel(
|
||||
input: LongitudinalPanelInput,
|
||||
): () => ElevationWindowResult | null {
|
||||
const { detail, chartWrap, structures, types, edit } = input;
|
||||
const chartWidth = input.renderWidth;
|
||||
|
||||
/** 측점선·알약을 끌어 놓았을 때 — 관인지 구조물인지는 공용 판별이 가른다. */
|
||||
const moveMark = (markId: string, toChainageM: number): void => {
|
||||
if (!edit) return;
|
||||
moveMarkById(markId, toChainageM, {
|
||||
structures,
|
||||
stations: detail.longitudinal.stations ?? [],
|
||||
edit,
|
||||
});
|
||||
};
|
||||
|
||||
const chart = buildLongitudinalChart({
|
||||
detail,
|
||||
selectedStationId: input.selectedStationId,
|
||||
exaggeration: input.exaggeration,
|
||||
selectStation: input.selectStation,
|
||||
stationInterval: input.stationInterval,
|
||||
chartWidth,
|
||||
chartHeight: input.chartHeight,
|
||||
minWidth: chartWidth,
|
||||
scrollLeft: input.keepScrollLeft,
|
||||
viewportWidth: chartWrap.clientWidth || chartWidth,
|
||||
// 계획고 편집 ▼ 버튼이 바닥에 붙으므로 X축·측점 라벨을 그만큼 밀어 올린다
|
||||
// (B05 와 같은 값 15px). 버튼이 없으면 0 — 종전 여백 그대로다.
|
||||
bottomInsetPx: input.grade ? 15 : 0,
|
||||
holdRange: input.holdRange,
|
||||
// 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동.
|
||||
onDragStation: edit ? moveMark : undefined,
|
||||
});
|
||||
|
||||
const nodes: Element[] = [chart.node];
|
||||
// 구조물 알약 레인 — B05 종단과 **같은 부품**을 그대로 쓴다. 배수관·세월교도 같은
|
||||
// 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일).
|
||||
if (structures.length && types.length) {
|
||||
nodes.push(
|
||||
buildStructureLane({
|
||||
structures,
|
||||
types,
|
||||
x: chart.toX,
|
||||
chainageAt: chart.toChainage,
|
||||
maxChainageM: chart.maxChainageM,
|
||||
widthPx: chartWidth,
|
||||
axisWidthPx: LONG_PAD.left,
|
||||
stationIntervalM: input.stationInterval,
|
||||
selectedId: null,
|
||||
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
|
||||
onSelect: (structureId) => {
|
||||
if (!structureId) return;
|
||||
const hit = structures.find((entry) => entry.structure_id === structureId);
|
||||
if (!hit) return;
|
||||
const at = structureAnchorM(hit);
|
||||
let best: { id: string; gap: number } | null = null;
|
||||
for (const section of detail.cross_sections) {
|
||||
const gap = Math.abs(section.chainage_m - at);
|
||||
if (!best || gap < best.gap) best = { id: section.station_id, gap };
|
||||
}
|
||||
if (best) input.selectStation(best.id);
|
||||
},
|
||||
// 알약 끌기도 B05 와 같게 받는다(2026-09-12 일원화) — 정본은 [저장]·[확정]에서.
|
||||
onMove: moveMark,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
chartWrap.replaceChildren(...nodes);
|
||||
// 계획선 편집 버튼층 — B05 와 **같은 부품**(`createEditOverlay`)이다(2026-09-12 사용자:
|
||||
// B05·B06 은 한 페이지인데 B06 에만 버튼이 없었다). 누른 값은 B05 와 같은 세션 초안에
|
||||
// 쌓이고 [저장]·[확정]에서 종단 정본으로 나간다.
|
||||
if (input.grade) {
|
||||
const editLayer = createEditOverlay({
|
||||
alignment: input.grade.alignment,
|
||||
width: chartWidth,
|
||||
x: chart.toX,
|
||||
step: input.grade.stepM,
|
||||
onStation: input.grade.onStation,
|
||||
});
|
||||
// 버튼층은 `inset: 0` 으로 부모를 꽉 채운다 — B05 는 부모가 그래프뿐이지만 B06 은
|
||||
// **알약 레인도 같은 칸 안**에 있어, 그대로 두면 ▼ 가 레인 위로 밀려난다(실측:
|
||||
// 그래프 바닥 231px 인데 버튼이 270px). 그래프 높이로 잘라 B05 와 같은 자리에 세운다.
|
||||
editLayer.style.height = `${input.chartHeight}px`;
|
||||
editLayer.style.bottom = "auto";
|
||||
chartWrap.append(editLayer);
|
||||
}
|
||||
// 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군
|
||||
// → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다).
|
||||
if (edit) {
|
||||
mountSectionStructureMenu(chartWrap, {
|
||||
structures,
|
||||
types,
|
||||
x: chart.toX,
|
||||
chainageAt: chart.toChainage,
|
||||
maxChainageM: chart.maxChainageM,
|
||||
edit,
|
||||
});
|
||||
}
|
||||
|
||||
// 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이
|
||||
// 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서
|
||||
// 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단).
|
||||
if (input.conversion) {
|
||||
const series = computeMassHaulSeries(
|
||||
detail.longitudinal,
|
||||
detail.cross_sections,
|
||||
input.conversion,
|
||||
input.naturalSpoilSlope,
|
||||
);
|
||||
const cross = series.find((entry) => entry.basis === "cross") ?? series[0];
|
||||
input.setMassBadge(cross ? badgeValuesFrom(cross.result) : null);
|
||||
} else {
|
||||
input.setMassBadge(null);
|
||||
}
|
||||
|
||||
// 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
|
||||
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다).
|
||||
if (chart.axis) {
|
||||
const overlay = buildStickyYAxis(chart.axis, input.chartHeight);
|
||||
// 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분).
|
||||
overlay.classList.add(Y_AXIS_WINDOW_CLASS);
|
||||
chartWrap.prepend(overlay);
|
||||
}
|
||||
chartWrap.scrollLeft = input.keepScrollLeft;
|
||||
|
||||
// 세로 창 갱신기 — 스크롤마다 종단을 변환으로 따라오게 한다(다시 그리지 않는다).
|
||||
return () => {
|
||||
const { fromM, toM } = visibleChainageRange(
|
||||
chart.toChainage,
|
||||
chart.maxChainageM,
|
||||
chartWrap.scrollLeft,
|
||||
chartWrap.clientWidth || chartWidth,
|
||||
);
|
||||
const next = visibleElevationRange(detail, fromM, toM);
|
||||
return applyElevationWindow(
|
||||
chartWrap,
|
||||
input.holdRange ? input.holdRange(next) : (next ?? undefined),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||
const SCROLL_SETTLE_MS = 80;
|
||||
|
||||
/**
|
||||
* 가로 스크롤에 세로 창을 따라붙인다 — **한 번만** 부른다(리스너를 남긴다).
|
||||
*
|
||||
* 세로 맞춤은 그리기가 아니라 **변환**이다(2026-09-04 사용자 확정, B05 와 같은 규칙).
|
||||
* 변환으로 못 살리는 자리에서만 스크롤이 멎은 뒤 한 번 다시 그린다.
|
||||
*/
|
||||
export function attachWindowScroll(
|
||||
chartWrap: HTMLElement,
|
||||
updater: () => (() => ElevationWindowResult | null) | null,
|
||||
redraw: () => void,
|
||||
): void {
|
||||
let settledScrollLeft = 0;
|
||||
let timer = 0;
|
||||
chartWrap.addEventListener("scroll", () => {
|
||||
const fitted = updater()?.() ?? null;
|
||||
window.clearTimeout(timer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
timer = window.setTimeout(() => {
|
||||
if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = chartWrap.scrollLeft;
|
||||
redraw();
|
||||
}, SCROLL_SETTLE_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획고 ▲▼ 를 **누르고 있는 동안 세로 창을 고정**한다(B05 와 같은 장치).
|
||||
*
|
||||
* 안 그러면 창이 새 계획선에 맞춰 다시 잡혀, 값은 바뀌는데 선은 제자리에 있는 것처럼
|
||||
* 보인다(2026-09-12 사용자 보고: 「버튼을 눌러도 계획 종단선이 안 바뀐다」). 손을 뗀 뒤
|
||||
* 한 번만 다시 맞춘다.
|
||||
*/
|
||||
export function createElevationHold(
|
||||
chartWrap: HTMLElement,
|
||||
redraw: () => void,
|
||||
): (next: { min: number; max: number } | null) => { min: number; max: number } | undefined {
|
||||
let editing = false;
|
||||
let held: { min: number; max: number } | undefined;
|
||||
chartWrap.addEventListener("pointerdown", (event) => {
|
||||
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
|
||||
editing = true;
|
||||
const release = (): void => {
|
||||
editing = false;
|
||||
window.removeEventListener("pointerup", release);
|
||||
window.removeEventListener("pointercancel", release);
|
||||
redraw();
|
||||
};
|
||||
window.addEventListener("pointerup", release);
|
||||
window.addEventListener("pointercancel", release);
|
||||
});
|
||||
return (next) => {
|
||||
if (editing) return held;
|
||||
held = next ?? undefined;
|
||||
return held;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Section_View_Menu.ts
|
||||
* B06 종단 그래프 위 **우클릭 메뉴** — B05 와 같은 부품(`mountStructureMenu`)을 그대로 쓴다.
|
||||
*
|
||||
* 왜 여기서도 여나(2026-09-12 사용자) — B05·B06 은 한 페이지다. 같은 그래프를 보면서
|
||||
* 한쪽에서만 넣고 뺄 수 있으면 사용자가 화면을 오가야 하고 직관도 깨진다.
|
||||
*
|
||||
* 정본에 바로 쓰지 않는다 — 추가·삭제·이동은 **세션에 예약**하고 [저장]·[확정]에서
|
||||
* 관 정본(`pipe_points.json`)으로 나간다(CLAUDE.md 5장). 세부 배수유역 재분할은 그때
|
||||
* 서버가 관 목록을 다시 받아 처리한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||
|
||||
/** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */
|
||||
export interface SectionStructureEdit {
|
||||
/** 관(계곡 통과 시설)을 그 자리에 넣는다. */
|
||||
addPipe: (chainageM: number) => void;
|
||||
/** 관을 뺀다. */
|
||||
removePipe: (chainageM: number) => void;
|
||||
/** 레지스트리 종류를 골라 구조물을 넣는다(좌측 「구조물 배치」와 같은 2단 메뉴). */
|
||||
addStructureType: (chainageM: number, typeId: string) => void;
|
||||
/** 구조물(관 아님)을 뺀다. */
|
||||
removeStructure: (structureId: string) => void;
|
||||
/** 관을 다른 측점으로 옮긴다(측점선·알약 끌기). */
|
||||
movePipe: (fromChainageM: number, toChainageM: number) => void;
|
||||
/** 구조물(관 아님)을 다른 측점으로 옮긴다. */
|
||||
moveStructure: (structureId: string, toChainageM: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점선·알약을 끌어 놓았다 — 그래프 위 id 로 구조물을 찾아 같은 예약 경로로 보낸다.
|
||||
* 종단 측점선의 id 는 **종단 정본 측점 id** 라 알약(구조물 id)과 다를 수 있다. 둘 다 받아
|
||||
* 같은 자리(누가거리)로 맞춘다.
|
||||
*/
|
||||
export function moveMarkById(
|
||||
markId: string,
|
||||
toChainageM: number,
|
||||
input: {
|
||||
structures: ReadonlyArray<StructureInstance>;
|
||||
stations: ReadonlyArray<{ station_id: string; chainage_m: number }>;
|
||||
edit: SectionStructureEdit;
|
||||
},
|
||||
): void {
|
||||
const direct = input.structures.find((entry) => String(entry.structure_id) === markId);
|
||||
if (direct) {
|
||||
moveStructureMark(input.structures, input.edit, markId, toChainageM);
|
||||
return;
|
||||
}
|
||||
const station = input.stations.find((entry) => String(entry.station_id) === markId);
|
||||
if (!station) return;
|
||||
// 측점선 id 로 온 경우 — 그 측점 자리에 선 구조물을 찾는다(관 매칭과 같은 0.51m).
|
||||
const near = input.structures.find(
|
||||
(entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < 0.51,
|
||||
);
|
||||
if (near) moveStructureMark(input.structures, input.edit, String(near.structure_id), toChainageM);
|
||||
}
|
||||
|
||||
/** 측점선·알약을 끌었을 때 — 관인지 구조물인지 갈라 같은 예약 경로로 보낸다. */
|
||||
function moveStructureMark(
|
||||
structures: ReadonlyArray<StructureInstance>,
|
||||
edit: SectionStructureEdit,
|
||||
structureId: string,
|
||||
toChainageM: number,
|
||||
): void {
|
||||
const hit = structures.find((entry) => String(entry.structure_id) === structureId);
|
||||
if (!hit) return;
|
||||
const from = structureAnchorM(hit);
|
||||
if (Math.abs(from - toChainageM) < 0.005) return;
|
||||
if (isPipeMark(hit)) edit.movePipe(from, toChainageM);
|
||||
else edit.moveStructure(structureId, toChainageM);
|
||||
}
|
||||
|
||||
/** 알약·우클릭이 쓰는 「관인가」 판정 — 관은 정본이 달라 지우는 길도 다르다. */
|
||||
export function isPipeMark(structure: StructureInstance): boolean {
|
||||
return String(structure.structure_id ?? "").startsWith("pipe-");
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 그릴 때마다 새로 만들어지므로 이
|
||||
* 함수도 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다(B05 와 같은 규칙).
|
||||
*/
|
||||
export function mountSectionStructureMenu(
|
||||
host: HTMLElement,
|
||||
input: {
|
||||
structures: ReadonlyArray<StructureInstance>;
|
||||
types: ReadonlyArray<StructureType>;
|
||||
x: (chainageM: number) => number;
|
||||
chainageAt: (px: number) => number;
|
||||
maxChainageM: number;
|
||||
edit: SectionStructureEdit;
|
||||
},
|
||||
): void {
|
||||
// 메뉴가 쓰는 최소 정보만 옮겨 담는다 — B06 에는 B05 의 비정규 측점 목록이 없다.
|
||||
const stations: IrregularStation[] = input.structures.map((structure) => {
|
||||
const chainage = structureAnchorM(structure);
|
||||
const pipe = isPipeMark(structure);
|
||||
return {
|
||||
id: String(structure.structure_id),
|
||||
station: 0,
|
||||
remainder: 0,
|
||||
chainage_m: chainage,
|
||||
structure:
|
||||
input.types.find((type) => type.type_id === structure.type_id)?.name ??
|
||||
String(structure.type_id),
|
||||
origin: pipe ? "pipe" : "user",
|
||||
} as IrregularStation;
|
||||
});
|
||||
|
||||
mountStructureMenu(host, {
|
||||
stations,
|
||||
x: input.x,
|
||||
chainageAt: input.chainageAt,
|
||||
maxChainageM: input.maxChainageM,
|
||||
onRemove: (station) => {
|
||||
if (station.origin === "pipe") input.edit.removePipe(station.chainage_m);
|
||||
else input.edit.removeStructure(station.id);
|
||||
},
|
||||
onAddPipe: (chainageM) => input.edit.addPipe(chainageM),
|
||||
structureTypes: input.types.map((type) => ({
|
||||
type_id: type.type_id,
|
||||
group: type.group,
|
||||
name: type.name,
|
||||
})),
|
||||
onAddStructureType: (chainageM, typeId) => input.edit.addStructureType(chainageM, typeId),
|
||||
});
|
||||
}
|
||||
@@ -152,6 +152,14 @@
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
/* 노견 바깥 끝 틱(2026-09-12) — 차도 틱보다 짧고 옅다. 차도 틱과 이 틱 사이가 노견이라,
|
||||
노면이 넓어졌을 때 차도가 늘었는지 노견이 늘었는지 눈으로 바로 갈린다. */
|
||||
.b06-chart__shoulder-tick {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
stroke-width: 1;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */
|
||||
.b06-chart__carriageway-label {
|
||||
fill: var(--color-royal-amethyst);
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
* 넘칠 만큼 길어지면 창 안에서 스크롤한다. z-index는 카드 안 오버레이 중 최상단. */
|
||||
.b06-structure-panel {
|
||||
position: absolute;
|
||||
top: var(--spacing-8);
|
||||
/* 좌측 상단에는 절·성토 면적표가 이미 서 있다 — 조정창을 같은 자리에 두면 표를 덮어
|
||||
수치가 안 보인다(2026-09-12 사용자). 표 높이(머리글+절토+성토 세 줄)만큼 내려 시작한다.
|
||||
아래를 기준으로 붙이지 않는 까닭은 종전 그대로다 — 낮은 횡단도에서 창이 잘린다. */
|
||||
top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem));
|
||||
left: var(--spacing-8);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
max-height: calc(100% - 2 * var(--spacing-8));
|
||||
max-height: calc(100% - 2 * var(--spacing-8) - var(--b06-areas-height, 3.6rem));
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-8);
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface DesignDrawingItem {
|
||||
| "landuse"
|
||||
| "plan_lidar"
|
||||
| "cross_standard"
|
||||
| "standard"
|
||||
| "blank";
|
||||
label: string;
|
||||
chainage_m: number | null;
|
||||
@@ -103,7 +102,6 @@ export interface DesignDrawingResponse {
|
||||
| "landuse"
|
||||
| "plan_lidar"
|
||||
| "cross_standard"
|
||||
| "standard"
|
||||
| "blank";
|
||||
label: string;
|
||||
drawing: CadDrawing;
|
||||
@@ -246,49 +244,5 @@ export function resetFrameTemplate(projectId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** 표준도 장 목록 — 제원 입력 칸이 쓰는 것만 추린 꼴. */
|
||||
export interface StandardSheetsResponse {
|
||||
status: string;
|
||||
sheet_count: number;
|
||||
structure_count: number;
|
||||
sheets: {
|
||||
key: string;
|
||||
title: string;
|
||||
type_id: string;
|
||||
member_count: number;
|
||||
options: Record<string, unknown>;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function fetchStandardSheets(projectId: string): Promise<StandardSheetsResponse> {
|
||||
return requestJson(`/projects/${projectId}/standard-sheets`);
|
||||
}
|
||||
|
||||
/** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */
|
||||
export function putStandardSheetSpec(
|
||||
projectId: string,
|
||||
body: {
|
||||
sheet_key: string;
|
||||
base_revision: number;
|
||||
stone_kind: string | null;
|
||||
stone_supply: string | null;
|
||||
back_len_cm: string | null;
|
||||
face_slope_ratio: string | null;
|
||||
foundation: string | null;
|
||||
stone_coeff_basis: string | null;
|
||||
fill_concrete_mpa: string | null;
|
||||
thickness_top_m: string | null;
|
||||
thickness_bottom_m: string | null;
|
||||
},
|
||||
): Promise<{ status: string; revision: number; changed: number; notes: string[] }> {
|
||||
return requestJson(`/projects/${projectId}/standard-sheets/spec`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** 구조물 정본 판번호 — 제원을 저장할 때 함께 보내야 다른 창 덮어쓰기를 막는다. */
|
||||
export function fetchStructureRevision(projectId: string): Promise<{ revision: number }> {
|
||||
return requestJson(`/projects/${projectId}/route/structures`);
|
||||
}
|
||||
// 표준도(구조물도) 장 목록·제원 저장 창구는 2026-09-13 B08 「구조물도」 탭으로 이관
|
||||
// (`B08_Quantity_UI_StructureSheet.ts`).
|
||||
|
||||
@@ -37,7 +37,16 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
_text_entity,
|
||||
polyline_entity,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import STONE_MASONRY, face_slope_ratio
|
||||
|
||||
# 기울기 판정은 구조물도 엔진이 가진 한 벌 — 2026-09-13 B08 로 이관(PLAN 3장). 받아 쓰기만 함.
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: F401
|
||||
SLOPE_TYPE_IDS,
|
||||
slope_of,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
|
||||
STONE_MASONRY,
|
||||
weep_hole_spec,
|
||||
)
|
||||
from common_util.common_util_excavation import (
|
||||
WALL_BLINDING_DEPTH_M,
|
||||
WALL_BLINDING_WIDTH_M,
|
||||
@@ -57,11 +66,6 @@ SCALE_MM_PER_M = 40.0
|
||||
#: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄.
|
||||
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"})
|
||||
|
||||
#: **전면 기울기 판정** 대상 — 그림 대상과 **다르다**. 큰돌쌓기는 그림은 못 그려도
|
||||
#: 기울기는 판정된다(품셈 13-6 「1:0.3 이상」). 한 목록으로 묶어 두었더니 큰돌쌓기를
|
||||
#: 그림에서 뺄 때 **장 제목의 「1:0.3」까지 사라졌다**(2026-09-09 화면 실측에서 잡음).
|
||||
SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"})
|
||||
|
||||
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
|
||||
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
|
||||
_NO_FIGURE_REASONS: dict[str, str] = {
|
||||
@@ -94,31 +98,6 @@ _LABEL_FONT = 4.0
|
||||
_DIM_FONT = 3.4
|
||||
|
||||
|
||||
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
|
||||
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다.
|
||||
|
||||
메/찰은 종류에서 온다. 큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면
|
||||
찰쌓기로 본다 — 품셈 13-6 은 둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다.
|
||||
"""
|
||||
options = sheet.get("options") or {}
|
||||
type_id = str(sheet.get("type_id") or "")
|
||||
if type_id == "masonry_dry":
|
||||
wet = False
|
||||
elif type_id == "boulder_masonry":
|
||||
wet = options.get("bond") != "메쌓기"
|
||||
else:
|
||||
wet = True
|
||||
return face_slope_ratio(
|
||||
options,
|
||||
wet=wet,
|
||||
height_m=float(sheet.get("height_m") or 0.0),
|
||||
# ⚠ 표를 만든 그 판정을 그대로 넘긴다 — 여기서 다시 가르면 근거를 못 받아
|
||||
# 종전값으로 떨어지고 **표와 갈린다**(2026-09-09 실측).
|
||||
face=sheet.get("face"),
|
||||
face_reason=str(sheet.get("face_reason") or ""),
|
||||
)
|
||||
|
||||
|
||||
def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]:
|
||||
"""(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다."""
|
||||
top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"]
|
||||
@@ -223,6 +202,9 @@ def build_figure(
|
||||
entities.append(base)
|
||||
|
||||
# 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀).
|
||||
# ⚠ 값은 **그 장의 제원**에서 읽는다 — 수량표와 같은 함수를 쓴다(2026-09-13). 기본 상수를
|
||||
# 적으면 사용자가 지름·면적을 고쳐도 그림 주석만 옛 값으로 남아 표와 갈린다.
|
||||
weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(sheet.get("options") or {})
|
||||
weep_y = height_m * 0.5
|
||||
entities.append(
|
||||
_line_entity(
|
||||
@@ -252,7 +234,7 @@ def build_figure(
|
||||
"left",
|
||||
),
|
||||
(
|
||||
f"물구멍 — {STONE_MASONRY['weep_hole_area_m2']:g}㎡당 1개소",
|
||||
f"물구멍 Ø{weep_diameter_mm} — {weep_area_m2:g}㎡당 1개소",
|
||||
(slope * weep_y + top_t + 0.16, weep_y),
|
||||
"left",
|
||||
),
|
||||
|
||||
@@ -10,14 +10,15 @@
|
||||
계획평면도가 쓰는 장 나눔 본을 따라야 하고 목록·라우터 네 곳이 함께 움직여야 한다(㉢).
|
||||
지금은 **값이 눈에 보이는 것**이 먼저라 한 장에 쌓고, 장 나눔은 그 다음이다.
|
||||
|
||||
⚠ 값을 여기서 셈하지 않는다 — `_Engine_Standard_Sheet.build_standard_sheets` 가 낸 것을
|
||||
⚠ 값을 여기서 셈하지 않는다 — `B08_Quantity_Engine_StructureSheet.build_standard_sheets` 가 낸 것을
|
||||
글자로 옮길 뿐이다. 셈이 두 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5장).
|
||||
|
||||
⚠ **2026-09-13 B07 도면 목록에서 빠짐**(사용자 확정 ③ — 구조물도는 B08 탭, PLAN 3장 ④).
|
||||
목록·라우터 배선은 걷어냈고 표·그림 조립만 남김 — B08 구조물도 상단 그림 일감이 다시 쓸 자리.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
@@ -36,18 +37,8 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure, figure_reason
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STANDARD_LAYER_ID = "standard"
|
||||
|
||||
#: 장 하나가 도면 하나 — 계획평면도와 같은 본을 따른다(`plan_lidar` / `plan_lidar_2`).
|
||||
SHEET_ID_PREFIX = "standard_sheet"
|
||||
|
||||
|
||||
def sheet_drawing_id(number: int, total: int) -> str:
|
||||
"""장이 하나면 `standard_sheet`, 여럿이면 `standard_sheet_2` 처럼 번호를 붙인다."""
|
||||
return SHEET_ID_PREFIX if total <= 1 and number <= 1 else f"{SHEET_ID_PREFIX}_{number}"
|
||||
|
||||
|
||||
# 종이 밀리미터. 실무 시트가 「공종 | 산출근거 | 수량 | 단위」 넉 줄이라 그대로 간다.
|
||||
# ⚠ **산출근거 칸을 넓게 잡는다** — 처음 176㎜ 로 냈더니 물구멍관·버림콘크리트처럼 단서가
|
||||
@@ -223,69 +214,3 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any])
|
||||
"entities": entities,
|
||||
"layers": [_layer(STANDARD_LAYER_ID, "표준도")],
|
||||
}
|
||||
|
||||
|
||||
def standard_payload(
|
||||
project_root: Path, section_modes: dict[float, str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""프로젝트 구조물을 제원 조합으로 묶은 장 목록. 실패해도 **빈 목록**을 낸다.
|
||||
|
||||
⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어
|
||||
모듈 맨 위에서 부르면 맞물린다.
|
||||
"""
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
|
||||
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
|
||||
|
||||
try:
|
||||
structures, names, _skipped = _collect_structures(str(project_root))
|
||||
# ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이
|
||||
# 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측).
|
||||
return build_standard_sheets(
|
||||
build_unit_table(structures, names, section_modes), section_modes
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root)
|
||||
return {"sheets": [], "structure_count": 0}
|
||||
|
||||
|
||||
def sheet_items(
|
||||
project_root: Path, section_modes: dict[float, str] | None = None
|
||||
) -> list[tuple[str, str]]:
|
||||
"""좌측 목록에 설 `(도면 id, 이름)`. 장이 없으면 **빈 장 하나**를 남긴다.
|
||||
|
||||
구조물이 없다고 단추가 통째로 사라지면 「없어진 것」처럼 보인다 — 눌러서 사유를 읽게 한다.
|
||||
"""
|
||||
sheets = standard_payload(project_root, section_modes).get("sheets") or []
|
||||
if not sheets:
|
||||
return [(SHEET_ID_PREFIX, "표준도")]
|
||||
total = len(sheets)
|
||||
return [
|
||||
(sheet_drawing_id(index, total), f"표준도 {index}장 ({sheet.get('title') or ''})".strip())
|
||||
for index, sheet in enumerate(sheets, start=1)
|
||||
]
|
||||
|
||||
|
||||
def standard_drawing_for(
|
||||
project_root: Path,
|
||||
drawing_id: str,
|
||||
label: str,
|
||||
section_modes: dict[float, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""프로젝트 구조물을 읽어 표준도 한 장을 만든다 — 라우터가 부르는 문.
|
||||
|
||||
⚠ 실패해도 도면은 연다 — 구조물 정본을 못 읽었다고 화면이 비면 사용자는 「고장」으로
|
||||
읽는다. 그때는 빈 표 대신 사유가 적힌 한 줄이 뜬다.
|
||||
⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어
|
||||
모듈 맨 위에서 부르면 맞물린다.
|
||||
"""
|
||||
payload = standard_payload(project_root, section_modes)
|
||||
sheets = payload.get("sheets") or []
|
||||
# 이 도면 id 가 가리키는 장 하나만 남긴다 — 한 면에 한 장(2026-09-09, 그림이 붙으면서).
|
||||
total = len(sheets)
|
||||
picked = [
|
||||
sheet
|
||||
for index, sheet in enumerate(sheets, start=1)
|
||||
if sheet_drawing_id(index, total) == drawing_id
|
||||
]
|
||||
return build_standard_drawing(drawing_id, label, {**payload, "sheets": picked})
|
||||
|
||||
@@ -31,7 +31,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
use_company_templates,
|
||||
use_title_fields,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Standard import section_modes_of
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
CROSS_STANDARD_ID,
|
||||
LANDUSE_ID,
|
||||
@@ -271,10 +270,7 @@ async def get_design_drawing_list(
|
||||
try:
|
||||
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
|
||||
designs = await _designs_by_chainage(route_id)
|
||||
modes = await section_modes_of(route_id)
|
||||
drawings = await asyncio.to_thread(
|
||||
_drawing_list, project_root, longitudinal_path, designs, modes
|
||||
)
|
||||
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
|
||||
return DesignDrawingListResponse(
|
||||
project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass
|
||||
)
|
||||
@@ -372,7 +368,6 @@ async def get_design_drawing(
|
||||
longitudinal_path,
|
||||
drawing_id,
|
||||
source_design,
|
||||
await section_modes_of(route_id),
|
||||
)
|
||||
return DesignDrawingResponse(
|
||||
project_id=str(project_id),
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"""B07 표준도(구조물도) 라우터 — 장 목록 조회와 **제원 입력**.
|
||||
|
||||
표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` 칸(돌 종류·조달·뒷길이·
|
||||
전면 기울기)을 그리는 화면이 없어(2026-09-09 실측) 그 자리를 여기가 맡는다.
|
||||
**장 하나 = 제원 조합 하나**라 한 번 고치면 그 조합의 개소 전부에 걸린다.
|
||||
|
||||
⚠ 값을 여기서 셈하지 않는다 — 정본(`structures.json`)에 적기만 하고 표·그림은 다음 조회에서
|
||||
그 정본으로 다시 선다. 표준도가 두 번째 정본이 되면 안 된다(CLAUDE.md 5장).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||
|
||||
|
||||
async def section_modes_of_project(project_id: UUID) -> dict[float, str]:
|
||||
"""프로젝트에서 노선을 찾아 단면유형 표를 낸다 — 노선을 모르면 빈 표."""
|
||||
from B06_Section.B06_Section_Repository import get_workflow_route_context
|
||||
from config.config_db import run_with_connection
|
||||
|
||||
try:
|
||||
context = await run_with_connection(get_workflow_route_context, project_id)
|
||||
route_id = int((context or {}).get("route_id") or 0)
|
||||
except Exception:
|
||||
logger.exception("B07 노선 조회 실패: project_id=%s", project_id)
|
||||
return {}
|
||||
return await section_modes_of(route_id) if route_id else {}
|
||||
|
||||
|
||||
async def section_modes_of(route_id: int) -> dict[float, str]:
|
||||
"""측점별 단면유형(`left_cut` 등) — 구조물이 **성토면인가 절토면인가**를 가르는 근거.
|
||||
|
||||
⚠ 이것을 안 넘기면 판정이 통째로 「가를 근거 없음」으로 떨어져 **전 구조물이 종전값
|
||||
1:0.3 으로 선다**(2026-09-09 실측). 값이 없는 것이 아니라 **안 넘긴 것**이었다.
|
||||
⚠ 표를 만드는 셈은 `section_modes_from_designs` 한 벌을 쓴다 — 부르는 쪽마다 다시
|
||||
짜면 B08 과 갈린다.
|
||||
"""
|
||||
from B06_Section.B06_Section_Repository import get_cross_section_designs
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs
|
||||
from config.config_db import run_with_connection
|
||||
|
||||
try:
|
||||
designs = await run_with_connection(get_cross_section_designs, route_id)
|
||||
except Exception:
|
||||
logger.exception("B07 단면유형 조회 실패: route_id=%s", route_id)
|
||||
return {}
|
||||
return section_modes_from_designs(designs)
|
||||
|
||||
|
||||
class StandardSheetSpecRequest(BaseModel):
|
||||
"""표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sheet_key: str
|
||||
base_revision: int = Field(ge=0)
|
||||
stone_kind: str | None = None
|
||||
stone_supply: str | None = None
|
||||
back_len_cm: int | str | None = None
|
||||
face_slope_ratio: float | str | None = None
|
||||
foundation: str | None = None
|
||||
stone_coeff_basis: str | None = None
|
||||
fill_concrete_mpa: str | None = None
|
||||
thickness_top_m: float | str | None = None
|
||||
thickness_bottom_m: float | str | None = None
|
||||
|
||||
|
||||
@router.put("/{project_id}/standard-sheets/spec")
|
||||
async def put_standard_sheet_spec(
|
||||
project_id: UUID, payload: StandardSheetSpecRequest
|
||||
) -> JSONResponse:
|
||||
"""장 하나의 제원을 고쳐 **그 조합의 구조물 전부**에 반영한다.
|
||||
|
||||
⚠ 값을 여기서 셈하지 않는다 — 정본(`structures.json`)에 적기만 하고, 표·그림은 다음
|
||||
조회에서 그 정본으로 다시 선다. 표준도가 두 번째 정본이 되면 안 된다(CLAUDE.md 5장).
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import standard_payload
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import (
|
||||
apply_spec,
|
||||
clean_spec,
|
||||
drop_unregistered,
|
||||
)
|
||||
|
||||
try:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path)).resolve()
|
||||
except Exception:
|
||||
logger.exception("B07 표준도 제원 저장 실패(경로): project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||||
)
|
||||
|
||||
modes = await section_modes_of_project(project_id)
|
||||
payload_sheets = await asyncio.to_thread(standard_payload, project_root, modes)
|
||||
sheets = payload_sheets.get("sheets") or []
|
||||
picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
|
||||
if picked is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "그 표준도 장을 찾지 못했습니다."},
|
||||
)
|
||||
member_ids = {
|
||||
str(m.get("structure_id")) for m in picked.get("members") or [] if m.get("structure_id")
|
||||
}
|
||||
|
||||
spec, notes = clean_spec(payload.model_dump(exclude={"sheet_key", "base_revision"}))
|
||||
# ⚠ 등록부에 없는 칸은 저장소가 거절한다 — 한 칸 때문에 **전부** 못 저장되지 않게 거른다.
|
||||
type_id = str(picked.get("type_id") or "")
|
||||
definition = structure_type_map().get(type_id)
|
||||
allowed = {field.key for field in definition.options} if definition else set()
|
||||
spec, missing = drop_unregistered(type_id, spec, allowed)
|
||||
notes.extend(missing)
|
||||
try:
|
||||
revision, stored = await asyncio.to_thread(load_structures, str(project_root))
|
||||
updated, changed = apply_spec(stored, member_ids, spec)
|
||||
new_revision = await asyncio.to_thread(
|
||||
save_structures, str(project_root), updated, base_revision=payload.base_revision
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("B07 표준도 제원 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
"revision": new_revision,
|
||||
"previous_revision": revision,
|
||||
"changed": changed,
|
||||
# 범위 밖 값·표에 없는 규격은 **막지 않고 알린다**(실무에 1:0.7 이 실재).
|
||||
"notes": notes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/standard-sheets")
|
||||
async def get_standard_sheets(project_id: UUID) -> JSONResponse:
|
||||
"""표준도(구조물도) **장 목록 + 하단표**.
|
||||
|
||||
⚠ 수량을 여기서 새로 셈하지 않는다 — B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고
|
||||
단위당으로 접기만** 한다(계산 자리는 한 곳, CLAUDE.md 5장).
|
||||
"""
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
|
||||
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
|
||||
|
||||
try:
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = str(Path(resolve_stored_project_path(stored_path)).resolve())
|
||||
except Exception:
|
||||
logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||||
)
|
||||
|
||||
modes = await section_modes_of_project(project_id)
|
||||
try:
|
||||
structures, names, skipped = await asyncio.to_thread(_collect_structures, project_root)
|
||||
unit_table = await asyncio.to_thread(build_unit_table, structures, names, modes)
|
||||
except Exception:
|
||||
logger.exception("B07 표준도 전개 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."},
|
||||
)
|
||||
|
||||
payload = build_standard_sheets(unit_table, modes)
|
||||
payload["status"] = "success"
|
||||
payload["project_id"] = str(project_id)
|
||||
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
|
||||
payload["skipped_structures"] = skipped
|
||||
return JSONResponse(content=payload)
|
||||
@@ -48,11 +48,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import (
|
||||
STANDARD_LABEL,
|
||||
build_standard_cross_drawing,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import (
|
||||
SHEET_ID_PREFIX,
|
||||
sheet_items,
|
||||
standard_drawing_for,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
|
||||
QUANTITY_VALUE_KEYS,
|
||||
)
|
||||
@@ -113,7 +108,6 @@ def _drawing_list(
|
||||
project_root: Path,
|
||||
longitudinal_path: Path,
|
||||
designs: dict[int, dict[str, Any]] | None = None,
|
||||
section_modes: dict[float, str] | None = None,
|
||||
) -> list[DesignDrawingItem]:
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
station_by_chainage = _station_map(longitudinal)
|
||||
@@ -205,10 +199,7 @@ def _drawing_list(
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
)
|
||||
)
|
||||
# 표준도 — **제원 조합마다 한 장**이라 장이 나뉜다(2026-09-09). 구조물이 없어도 한 장은
|
||||
# 남긴다: 단추가 사라지면 「없어진 것」처럼 보이고 사유를 읽을 자리도 없어진다.
|
||||
for drawing_id, label in sheet_items(project_root, section_modes):
|
||||
drawings.append(DesignDrawingItem(id=drawing_id, kind="standard", label=label))
|
||||
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④.
|
||||
_store_drawing_numbers(project_root, drawings)
|
||||
return drawings
|
||||
|
||||
@@ -410,7 +401,6 @@ def _read_drawing(
|
||||
longitudinal_path: Path,
|
||||
drawing_id: str,
|
||||
stored_design: dict[str, Any] | None = None,
|
||||
section_modes: dict[float, str] | None = None,
|
||||
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
|
||||
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
|
||||
|
||||
@@ -442,11 +432,6 @@ def _read_drawing(
|
||||
stored_table = manifest_entry.get("quantity_table")
|
||||
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
|
||||
return kind, label, saved, True, table
|
||||
if drawing_id == SHEET_ID_PREFIX or drawing_id.startswith(f"{SHEET_ID_PREFIX}_"):
|
||||
# 표준도 — 제원 조합 한 벌이 한 장. 도각은 두르지 않는다(2026-09-08 사용자 지시).
|
||||
label = dict(sheet_items(project_root, section_modes)).get(drawing_id, "표준도")
|
||||
drawing = standard_drawing_for(project_root, drawing_id, label, section_modes)
|
||||
return "standard", label, drawing, False, None
|
||||
|
||||
if drawing_id == COVER_ID:
|
||||
# 표지는 설계 자료를 쓰지 않는다 — 템플릿 한 장이 곧 도면이다.
|
||||
|
||||
@@ -20,8 +20,7 @@ class DesignDrawingItem(BaseModel):
|
||||
"landuse",
|
||||
"plan_lidar",
|
||||
"cross_standard",
|
||||
# standard: 표준도(구조물도) — 제원 조합 하나가 한 장.
|
||||
"standard",
|
||||
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — kind 도 뺌.
|
||||
"blank",
|
||||
]
|
||||
label: str
|
||||
@@ -59,8 +58,7 @@ class DesignDrawingResponse(BaseModel):
|
||||
"landuse",
|
||||
"plan_lidar",
|
||||
"cross_standard",
|
||||
# standard: 표준도(구조물도) — 제원 조합 하나가 한 장.
|
||||
"standard",
|
||||
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — kind 도 뺌.
|
||||
"blank",
|
||||
]
|
||||
label: str
|
||||
|
||||
@@ -38,22 +38,14 @@ import {
|
||||
exportDrawing,
|
||||
fetchDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
fetchStandardSheets,
|
||||
fetchStructureRevision,
|
||||
invalidateDesignDrawing,
|
||||
putStandardSheetSpec,
|
||||
type CadDrawing,
|
||||
type DesignDrawingItem,
|
||||
type DesignDrawingResponse,
|
||||
type QuantityTable,
|
||||
type StandardSheetsResponse,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
||||
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
|
||||
import {
|
||||
buildStandardSpecPanel,
|
||||
type StandardSpecResult,
|
||||
} from "./B07_DesignDetail_UI_StandardSpec";
|
||||
|
||||
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
||||
interface DesignMeta {
|
||||
@@ -102,10 +94,6 @@ const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
||||
* -------------------------------------------------------------------------- */
|
||||
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
/** 표준도 장 목록 — 제원 폼이 쓰는 값. 진입 때 한 번 받고 저장 뒤 다시 받는다. */
|
||||
let standardSheets: StandardSheetsResponse["sheets"] = [];
|
||||
/** 저장 뒤 폼이 다시 그려질 때 이어서 보여 줄 안내. */
|
||||
let specNotes: string[] = [];
|
||||
let workflowState: WorkflowState | undefined;
|
||||
let drawings: DesignDrawingItem[] = [];
|
||||
let drawingError: string | undefined;
|
||||
@@ -119,14 +107,6 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
if (drawingResult.status === "fulfilled") {
|
||||
drawings = drawingResult.value.drawings;
|
||||
devBypass = drawingResult.value.dev_bypass === true;
|
||||
if (drawings.some((item) => item.kind === "standard") && projectId) {
|
||||
// 표준도 제원 폼이 쓸 장 목록 — 실패해도 도면은 열려야 하므로 조용히 넘어간다.
|
||||
try {
|
||||
standardSheets = (await fetchStandardSheets(projectId)).sheets;
|
||||
} catch {
|
||||
standardSheets = [];
|
||||
}
|
||||
}
|
||||
} else
|
||||
drawingError =
|
||||
drawingResult.reason instanceof Error
|
||||
@@ -213,52 +193,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const infoPanelHost = document.createElement("div");
|
||||
infoPanelHost.className = "b07-info-host";
|
||||
|
||||
const showStandardSpec = (drawing: DesignDrawingItem): void => {
|
||||
const index = Number(/_(\d+)$/.exec(drawing.id)?.[1] ?? "1") - 1;
|
||||
const sheet = standardSheets[index];
|
||||
if (!projectId || !sheet) {
|
||||
infoPanelHost.replaceChildren();
|
||||
return;
|
||||
}
|
||||
// 판정된 기울기는 **칸에 적지 않고 도움말로만** 비춘다 — 적어 두면 「안 정함」이 사라진다.
|
||||
const judged = /1:([\d.]+)/.exec(drawing.label)?.[1] ?? null;
|
||||
infoPanelHost.replaceChildren(
|
||||
buildStandardSpecPanel(
|
||||
sheet,
|
||||
judged,
|
||||
async (result: StandardSpecResult) => {
|
||||
const { revision } = await fetchStructureRevision(projectId);
|
||||
const saved = await putStandardSheetSpec(projectId, {
|
||||
...result,
|
||||
base_revision: revision,
|
||||
});
|
||||
// 정본이 바뀌었으니 표·그림을 **다시 받아** 그린다 — 화면이 두 번째 정본이 되면 안 된다.
|
||||
drawingCache.delete(drawing.id);
|
||||
standardSheets = (await fetchStandardSheets(projectId)).sheets;
|
||||
// ⚠ 장 제목에 제원이 들어 있다 — 고쳤으면 **좌측 단추 글자도 따라가야** 한다.
|
||||
// 목록을 통째로 다시 받지 않고 표준도 줄만 갈아 끼운다(2026-09-09 실화면에서 잡음).
|
||||
for (const [order, item] of drawings.filter((d) => d.kind === "standard").entries()) {
|
||||
const fresh = standardSheets[order];
|
||||
if (!fresh) continue;
|
||||
item.label = `표준도 ${order + 1}장 (${fresh.title})`;
|
||||
const name = findButton(item.id)?.querySelector(".b07-drawing-button__name");
|
||||
if (name) name.textContent = item.label;
|
||||
}
|
||||
specNotes = [`${saved.changed}개소에 반영했습니다.`, ...(saved.notes ?? [])];
|
||||
await loadDrawing(drawing, currentIndex);
|
||||
return specNotes;
|
||||
},
|
||||
specNotes,
|
||||
),
|
||||
);
|
||||
specNotes = [];
|
||||
};
|
||||
|
||||
// 표준도 제원 칸·[여기서 고치기]는 2026-09-13 B08 「구조물도」 탭으로 이관(PLAN 3장 ④).
|
||||
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
|
||||
if (drawing.kind === "standard") {
|
||||
showStandardSpec(drawing);
|
||||
return;
|
||||
}
|
||||
if (drawing.kind !== "cross") {
|
||||
infoPanelHost.replaceChildren();
|
||||
return;
|
||||
|
||||
@@ -37,8 +37,6 @@ export const DRAWING_GROUPS: readonly {
|
||||
{ label: "횡단면도", kind: "cross" },
|
||||
{ label: "토적도(유토곡선)", kind: "mass_haul" },
|
||||
{ label: "유역도(배수 유역도)", kind: "watershed" },
|
||||
// 표준도는 **제원 조합마다 한 장**이라 장이 나뉜다(2026-09-09) — 계획평면도와 같은 묶음.
|
||||
{ label: "표준도", idPrefix: "standard_sheet" },
|
||||
{ label: "용지도", idPrefix: "landuse" },
|
||||
];
|
||||
|
||||
|
||||
@@ -337,73 +337,4 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 표준도 제원 입력 — 좌측 목록 아래 정보 칸에 선다. 장 하나가 곧 제원 조합 하나다. */
|
||||
.b07-spec {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.b07-spec__title {
|
||||
margin: 0;
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b07-spec__scope {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-spec__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b07-spec__label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-spec__input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-buttons);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* 「비우면 자동」처럼 **칸의 뜻**을 적는 자리 — 값이 아니라 규칙을 말한다. */
|
||||
.b07-spec__hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b07-spec__save {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-buttons);
|
||||
background: var(--color-primary, #7c3aed);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b07-spec__save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 저장 뒤 안내 — 막지 않고 알리는 자리(품셈 범위 밖 기울기 등). */
|
||||
.b07-spec__notes {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
|
||||
|
||||
@@ -366,6 +366,26 @@ def basis_from_source(lines: list[str], line_no: int) -> tuple[float | None, str
|
||||
return None, None
|
||||
|
||||
|
||||
#: ⚠ **밑수가 절 이름에만 있는 표** — 「4-2-2. 1,000㎡당」·「4-2-1. 100본당」(2026-09-13 판정).
|
||||
#: 본문·표 안 어디에도 「(단위: …)」가 없어 밑수가 비어 있었다(B09 가 막아 금액은 안 섰음).
|
||||
#: ⚠ **이름 전체가 「수 + 단위 + 당」일 때만** 뽑는다 — 「나무주사(100본당)」처럼 섞인 것은
|
||||
#: 본문이 이미 주고, 「자재의 1회당 표준 운반량」 같은 설명문은 밑수가 아니다. 짐작하지 않는다.
|
||||
NAME_BASIS_RE = re.compile(
|
||||
r"^([\d,]+(?:\.\d+)?)\s*(㎥|㎡|㏊|ha|㎞|km|m|매|본|개소|개|주|kg|㎏|톤|ton)\s*당$"
|
||||
)
|
||||
|
||||
|
||||
def basis_from_name(name: str) -> tuple[float | None, str | None]:
|
||||
"""절 이름이 곧 밑수인 경우만 `(수, 단위)`. 아니면 `(None, None)`."""
|
||||
found = NAME_BASIS_RE.match(norm(name))
|
||||
if found is None:
|
||||
return None, None
|
||||
try:
|
||||
return float(found.group(1).replace(",", "")), found.group(2)
|
||||
except ValueError:
|
||||
return None, None
|
||||
|
||||
|
||||
def detect_basis(table: dict[str, Any]) -> tuple[float | None, str | None]:
|
||||
"""「100㎥당」 같은 밑수. 없으면 `(None, None)` — 단위당 1 로 단정하지 않는다.
|
||||
|
||||
@@ -599,6 +619,11 @@ def build() -> dict[str, Any]:
|
||||
basis_source = "표 안" if basis_unit else None
|
||||
else:
|
||||
basis_source = "본문"
|
||||
# 본문·표 안에 없을 때만 **절 이름**을 본다(4-2-2 「1,000㎡당」) — 이름 전체가 밑수일 때만.
|
||||
if basis_unit is None and number in by_number:
|
||||
name_qty, name_unit = basis_from_name(by_number[number]["name"])
|
||||
if name_unit:
|
||||
basis_qty, basis_unit, basis_source = name_qty, name_unit, "절 이름"
|
||||
if basis_unit:
|
||||
basis_found += 1
|
||||
if basis_quantity_is_grouped(basis_qty):
|
||||
@@ -665,6 +690,11 @@ def build() -> dict[str, Any]:
|
||||
node["tables"].append(entry)
|
||||
attached += 1
|
||||
|
||||
# 부모 모양(명세 2장) — 기본 choose_one, 합산형은 사람이 적은 것만.
|
||||
from B08_Quantity.B08_Quantity_Build_WorkItemMaster_Parents import apply_parent_modes
|
||||
|
||||
apply_parent_modes(nodes)
|
||||
|
||||
src_meta = {
|
||||
"dataset_id": data["dataset_id"],
|
||||
"effective_date": data["effective_date"],
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""공종 마스터 — 부모 공종의 모양 `parent_mode` (명세 2장 · 2026-09-13 축 C Ⓐ).
|
||||
|
||||
아래 절이 있는 공종(부모)은 두 모양이고 규칙이 다르다.
|
||||
|
||||
choose_one 갈래 고르기형 — 잎 하나를 고른다. **부모에 금액을 붙이지 않는다.** (기본)
|
||||
sum_steps 단계 합산형 — 내역 한 줄이 곧 부모이고, 일위대가는 **잎들의 합**으로 조립한다.
|
||||
|
||||
⚠ **자동 판정 금지** — 표 모양으로 짐작하면 조용히 틀린다. 합산형은 아래에 **사람이 적은 것**만.
|
||||
⚠ 가중치(`weight`)도 사람이 적는다 — 원문 절 제목이 준 비율만(「발파 10%」·「깎기(90%)」).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
PARENT_CHOOSE_ONE = "choose_one"
|
||||
PARENT_SUM_STEPS = "sum_steps"
|
||||
|
||||
#: 단계 합산형 부모 — (잎 코드, 가중치) 차례와 근거. ⚠ 여기 없는 부모는 전부 `choose_one`.
|
||||
SUM_STEPS: dict[str, dict[str, Any]] = {
|
||||
"FP-09-04": {
|
||||
"steps": (("FP-09-04-01", "1"), ("FP-09-04-02", "1")),
|
||||
"basis": (
|
||||
"품셈 9-4 암절취 = 9-4-1 암파쇄 + 9-4-2 집토 — 같은 ㎥ 를 깨고 모음"
|
||||
"(지식DB 토공_수량 「암절취 (암파쇄ㆍ집토) | 9-4」)"
|
||||
),
|
||||
},
|
||||
"FP-09-05": {
|
||||
"steps": (("FP-09-05-01", "0.1"), ("FP-09-05-02", "0.9"), ("FP-09-05-03", "1")),
|
||||
"basis": (
|
||||
"품셈 9-5 발파암 — 절 제목 「9-5-1 육상, 암석 절취(발파 10%)」·「9-5-2 깎기(90%)」·"
|
||||
"「9-5-3 집토」(지식DB 토공_수량 「발파암 (절취 10 %ㆍ깎기 90 %ㆍ집토) | 9-5」)"
|
||||
),
|
||||
},
|
||||
"FP-12-38": {
|
||||
"steps": (("FP-12-38-02", "1"), ("FP-12-38-03", "1")),
|
||||
"basis": (
|
||||
"품셈 12-38 유로폼 = 12-38-2 사용수량(자재) + 12-38-3 설치 및 해체(품) — "
|
||||
"12-38-1 사용횟수는 금액 단계가 아니라 사용수량의 잔존율 조건"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def apply_parent_modes(nodes: list[dict[str, Any]]) -> None:
|
||||
"""부모 공종에 `parent_mode`(합산형이면 `steps`·`steps_basis` 까지)를 단다 — 자리에서.
|
||||
|
||||
⚠ 선언한 잎이 그 부모의 자식이 아니면 멈춘다 — 판이 바뀌어 절 번호가 옮겨 간 자리다.
|
||||
"""
|
||||
children: dict[str, list[str]] = {}
|
||||
for node in nodes:
|
||||
if node.get("parent_code"):
|
||||
children.setdefault(node["parent_code"], []).append(node["work_item_code"])
|
||||
for node in nodes:
|
||||
code = node["work_item_code"]
|
||||
if code not in children:
|
||||
continue
|
||||
declared = SUM_STEPS.get(code)
|
||||
if declared is None:
|
||||
node["parent_mode"] = PARENT_CHOOSE_ONE
|
||||
continue
|
||||
strays = [step for step, _ in declared["steps"] if step not in children[code]]
|
||||
if strays:
|
||||
raise ValueError(f"{code} 합산 단계가 자식이 아닙니다: {strays}")
|
||||
node["parent_mode"] = PARENT_SUM_STEPS
|
||||
node["steps"] = [{"code": step, "weight": weight} for step, weight in declared["steps"]]
|
||||
node["steps_basis"] = declared["basis"]
|
||||
missing = [code for code in SUM_STEPS if code not in children]
|
||||
if missing:
|
||||
raise ValueError(f"합산형으로 적은 부모가 마스터에 없습니다: {missing}")
|
||||
@@ -176,17 +176,21 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
|
||||
# 1.9.2 잡관목제거 벌목(5m미만) 11,035㎡ @882 ← 같은 11,035㎡
|
||||
# ⚠⚠ **이중계상이 아니다** — 한 면적에 **다른 두 작업**이 얹히는 것이라 실무가 그렇게 적는다.
|
||||
# (같은 작업을 두 축에서 두 번 세는 것과는 다른 자리다.)
|
||||
# ⚠ 잡관목제거는 **품셈에 그 이름이 없다** — 실무는 별도 단가(영월 D00033)를 씀.
|
||||
# 공종 없는 줄 보류(확정 5차 3번)에 걸리므로 **코드 없이 서고 사유가 붙는다.**
|
||||
# ⭐ 2026-09-13 브레인 판정 — 지장목제거는 **품셈에 있다**(벌목 4장 + 제근 9-20~21 단계 합산).
|
||||
# 잡관목제거 = 단목베기 1,000㎡당 「5m 미만」(4-2-2) · 뿌리뽑기 = 제근(9-21, 판정 Ⓑ).
|
||||
# 준비공 「제근·뿌리다듬기」는 같은 면적이라 **참조로만** 보인다(이중계상 막이).
|
||||
for item, why in (
|
||||
(
|
||||
"뿌리뽑기",
|
||||
"확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름(같은 면적을 나눠 씀 · 이중계상 아님)",
|
||||
"확정 5차 2번 — 실무가 뿌리뽑기·잡관목제거 두 줄로 가름"
|
||||
"(같은 면적을 나눠 씀 · 이중계상 아님). 품셈 9-21 제근으로 이음 —"
|
||||
" 준비공 「제근·뿌리다듬기」는 참조로만 보임(2026-09-13 판정)",
|
||||
),
|
||||
(
|
||||
"잡관목제거",
|
||||
"확정 5차 2번 — 같은 면적에 얹히는 다른 작업(이중계상 아님)."
|
||||
" ⚠ 품셈에 그 이름이 없어 실무는 별도 단가를 씀(영월 D00033) — 공종 보류 대상",
|
||||
" 품셈 4-2-2 단목베기(1,000㎡당) 「5m 미만」으로 이음"
|
||||
"(2026-09-13 판정 · 실무 영월 1.9.2)",
|
||||
),
|
||||
):
|
||||
rows.append(
|
||||
|
||||
@@ -16,7 +16,14 @@
|
||||
보정량 = 체적 × 토량환산계수(다짐)
|
||||
절취한 흙이 다져지면 줄거나 부푼다. 성토에 쓸 수 있는 양으로 환산한 것이 보정량이다.
|
||||
계수의 유일한 정의처는 `config.config_system_design.EARTHWORK_CONVERSION_FACTORS` 이며
|
||||
여기서 값을 다시 적지 않는다.
|
||||
여기서 값을 다시 적지 않는다. 프로젝트가 고른 값이 있으면 라우터가
|
||||
`earthwork_conversion_factors(settings)` 로 풀어 `factors` 로 넘긴다 — 기본값은 그대로다.
|
||||
|
||||
측구터파기 토사·암 — 설계가 가른 값을 그대로 읽는다
|
||||
B06 이 지반 유형 + 암반 경계선으로 이미 갈라 냈다(`ditch_soil_area_m2`·
|
||||
`ditch_rock_area_m2` · 사유 `ditch_split_basis`). 여기서 다시 나누지 않는다 —
|
||||
나누면 같은 측구가 횡단도와 토적표에서 다른 숫자로 선다. 가름이 붙기 전 저장분만
|
||||
절토 면적비 안분으로 떨어지고, 그 줄에는 사유가 남는다(`_split_ditch`).
|
||||
|
||||
⚠ 숫자는 자르지 않는다 (PLAN 8-16)
|
||||
품셈 1-2-2 의 소수 자리는 **표기 규칙**이다. 계산은 전정밀로 두고 화면·출력에서만
|
||||
@@ -34,10 +41,22 @@ from config.config_system_design import EARTHWORK_CONVERSION_FACTORS
|
||||
# 값이 없으면 리핑암으로 본다(발파암보다 보수적으로 적은 쪽).
|
||||
_DEFAULT_ROCK_KIND = "ripping_rock"
|
||||
|
||||
# 측구 가름 근거 — 설계가 가른 값이 없어 절토 면적비로 안분했을 때만 붙는다.
|
||||
# B06 가 내는 네 갈래(`no_ditch`·`soil_ground`·`rock_boundary`·`rock_ground_no_boundary`)와
|
||||
# 섞이지 않게 이름을 따로 둔다. 이 값이 표에 보이면 **설계가 가른 것이 아니다.**
|
||||
_FALLBACK_BASIS = "cut_area_ratio_fallback"
|
||||
|
||||
def _factor(kind: str) -> float:
|
||||
#: 폴백을 탄 측점 줄에 남기는 사유. 숫자만 봐서는 안분인지 설계값인지 알 수 없다.
|
||||
_FALLBACK_NOTE = "측구 가름값이 설계에 없어 절토 토사:암 면적비로 안분함"
|
||||
|
||||
|
||||
#: 계수 묶음의 모양 — `{지반유형: {"compacted": C}}`.
|
||||
Factors = dict[str, dict[str, float]]
|
||||
|
||||
|
||||
def _factor(kind: str, factors: Factors) -> float:
|
||||
"""지반유형 → 다짐 환산계수. 모르는 유형이면 토사로 본다."""
|
||||
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) or EARTHWORK_CONVERSION_FACTORS["soil"]
|
||||
entry = factors.get(kind) or factors["soil"]
|
||||
return float(entry["compacted"])
|
||||
|
||||
|
||||
@@ -50,6 +69,11 @@ class StationArea:
|
||||
cut_rock_area_m2: float = 0.0
|
||||
fill_area_m2: float = 0.0
|
||||
ditch_area_m2: float = 0.0
|
||||
# 측구 가름은 **설계가 낸 값**이다(B06 `ditch_soil_area_m2`·`ditch_rock_area_m2`).
|
||||
# `None` 은 「설계가 안 냈다」는 뜻이고 0.0 과 다르다 — 0.0 은 설계가 낸 「없음」이다.
|
||||
ditch_soil_area_m2: float | None = None
|
||||
ditch_rock_area_m2: float | None = None
|
||||
ditch_split_basis: str | None = None
|
||||
cut_rock_kind: str | None = None
|
||||
|
||||
@classmethod
|
||||
@@ -58,12 +82,19 @@ class StationArea:
|
||||
value = design.get(key)
|
||||
return float(value) if isinstance(value, (int, float)) else 0.0
|
||||
|
||||
def optional(key: str) -> float | None:
|
||||
value = design.get(key)
|
||||
return float(value) if isinstance(value, (int, float)) else None
|
||||
|
||||
return cls(
|
||||
chainage_m=float(chainage_m),
|
||||
cut_soil_area_m2=num("cut_soil_area_m2"),
|
||||
cut_rock_area_m2=num("cut_rock_area_m2"),
|
||||
fill_area_m2=num("fill_area_m2"),
|
||||
ditch_area_m2=num("ditch_area_m2"),
|
||||
ditch_soil_area_m2=optional("ditch_soil_area_m2"),
|
||||
ditch_rock_area_m2=optional("ditch_rock_area_m2"),
|
||||
ditch_split_basis=design.get("ditch_split_basis") or None,
|
||||
cut_rock_kind=design.get("cut_rock_kind") or None,
|
||||
)
|
||||
|
||||
@@ -92,29 +123,52 @@ class EarthworkRow:
|
||||
diverted_m3: float = 0.0
|
||||
balance_m3: float = 0.0
|
||||
cumulative_m3: float = 0.0
|
||||
# 측구를 무슨 근거로 갈랐나 — 설계가 낸 사유를 그대로 싣는다(빈 문자열은 사유 없음).
|
||||
ditch_split_basis: str = ""
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _split_ditch(area: StationArea) -> tuple[float, float]:
|
||||
"""측구터파기 단면적을 토사·암으로 가른다.
|
||||
def _split_ditch(area: StationArea) -> tuple[float, float, str]:
|
||||
"""측구터파기 단면적을 토사·암으로 가른다 — **설계가 가른 값을 그대로 읽는다.**
|
||||
|
||||
⚠ TODO(미결 · PLAN 8-4b) — 설계가 측구를 토사·암으로 나눠 주지 않는다(`ditch_area_m2`
|
||||
한 값뿐). 실무 토적표는 둘로 갈라 적으므로, **그 측점의 절토 토사:암 면적비로 안분**한다.
|
||||
측구는 절토부에 파므로 같은 지반을 만난다는 것이 근거다. 설계가 측구 지반을 따로 내주게
|
||||
되면 이 함수만 갈아끼운다.
|
||||
가름의 주인은 횡단 설계다. B06 이 절토 분리와 같은 근거(지반 유형 + 암반 경계선)로
|
||||
`ditch_soil_area_m2`·`ditch_rock_area_m2` 를 내고 사유를 `ditch_split_basis` 로
|
||||
함께 낸다. 토적표가 여기서 다시 나누면 **같은 측구가 횡단도와 토적표에서 다른 숫자로
|
||||
선다** — 그 어긋남을 없애는 자리다.
|
||||
|
||||
못 가른 측점(암 지반인데 암반 경계선이 없음)은 설계가 **전량 암 + 사유**로 내므로
|
||||
여기서 손보지 않는다.
|
||||
|
||||
⚠ 폴백은 **설계값이 아예 없을 때만** — 가름이 붙기 전 저장분이다. 그때만 그 측점의
|
||||
절토 토사:암 면적비로 안분하고, 근거를 `cut_area_ratio_fallback` 으로 남겨
|
||||
「설계가 가른 것이 아니다」가 표에 드러나게 한다.
|
||||
"""
|
||||
if area.ditch_soil_area_m2 is not None or area.ditch_rock_area_m2 is not None:
|
||||
return (
|
||||
float(area.ditch_soil_area_m2 or 0.0),
|
||||
float(area.ditch_rock_area_m2 or 0.0),
|
||||
area.ditch_split_basis or "",
|
||||
)
|
||||
|
||||
ditch = area.ditch_area_m2
|
||||
if ditch <= 0:
|
||||
return 0.0, 0.0
|
||||
return 0.0, 0.0, "no_ditch"
|
||||
soil, rock = area.cut_soil_area_m2, area.cut_rock_area_m2
|
||||
total = soil + rock
|
||||
if total <= 0:
|
||||
return ditch, 0.0 # 절토가 없으면 토사로 본다.
|
||||
return ditch * soil / total, ditch * rock / total
|
||||
return ditch, 0.0, _FALLBACK_BASIS # 절토가 없으면 토사로 본다.
|
||||
return ditch * soil / total, ditch * rock / total, _FALLBACK_BASIS
|
||||
|
||||
|
||||
def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]:
|
||||
"""측점 목록 → 토적표 줄 목록. 측점은 이정 순으로 정렬해 받는다."""
|
||||
def build_rows(
|
||||
stations: Iterable[StationArea], factors: Factors | None = None
|
||||
) -> list[EarthworkRow]:
|
||||
"""측점 목록 → 토적표 줄 목록. 측점은 이정 순으로 정렬해 받는다.
|
||||
|
||||
`factors` 는 프로젝트가 고른 토량환산계수다(`earthwork_conversion_factors`).
|
||||
안 주면 정본 기본값이 선다 — 설정을 안 읽는 자리(시험·되짚기)를 위한 것이다.
|
||||
"""
|
||||
factors = factors or EARTHWORK_CONVERSION_FACTORS
|
||||
ordered = sorted(stations, key=lambda s: s.chainage_m)
|
||||
rows: list[EarthworkRow] = []
|
||||
previous: StationArea | None = None
|
||||
@@ -122,9 +176,9 @@ def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]:
|
||||
cumulative = 0.0
|
||||
|
||||
for station in ordered:
|
||||
ditch_soil, ditch_rock = _split_ditch(station)
|
||||
soil_factor = _factor("soil")
|
||||
rock_factor = _factor(station.cut_rock_kind or _DEFAULT_ROCK_KIND)
|
||||
ditch_soil, ditch_rock, ditch_basis = _split_ditch(station)
|
||||
soil_factor = _factor("soil", factors)
|
||||
rock_factor = _factor(station.cut_rock_kind or _DEFAULT_ROCK_KIND, factors)
|
||||
row = EarthworkRow(
|
||||
chainage_m=station.chainage_m,
|
||||
cut_soil_area_m2=station.cut_soil_area_m2,
|
||||
@@ -132,7 +186,10 @@ def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]:
|
||||
ditch_soil_area_m2=ditch_soil,
|
||||
ditch_rock_area_m2=ditch_rock,
|
||||
fill_area_m2=station.fill_area_m2,
|
||||
ditch_split_basis=ditch_basis,
|
||||
)
|
||||
if ditch_basis == _FALLBACK_BASIS:
|
||||
row.notes.append(_FALLBACK_NOTE)
|
||||
if previous is not None:
|
||||
distance = station.chainage_m - previous.chainage_m
|
||||
row.distance_m = distance
|
||||
@@ -193,12 +250,17 @@ def totals(rows: list[EarthworkRow]) -> dict[str, float]:
|
||||
return {key: sum(getattr(row, key) for row in rows) for key in keys}
|
||||
|
||||
|
||||
def build_table(stations: Iterable[StationArea]) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양. 값은 자르지 않는다(PLAN 8-16)."""
|
||||
rows = build_rows(stations)
|
||||
def build_table(stations: Iterable[StationArea], factors: Factors | None = None) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양. 값은 자르지 않는다(PLAN 8-16).
|
||||
|
||||
`conversion_factors` 로 **실제로 쓴 계수**를 되싣는다 — 프로젝트가 고른 값이면
|
||||
그것이 나가야 화면이 「무엇으로 셌나」를 그대로 보인다.
|
||||
"""
|
||||
factors = factors or EARTHWORK_CONVERSION_FACTORS
|
||||
rows = build_rows(stations, factors)
|
||||
return {
|
||||
"method": "average_end_area",
|
||||
"conversion_factors": EARTHWORK_CONVERSION_FACTORS,
|
||||
"conversion_factors": factors,
|
||||
"rows": [row.__dict__ if not hasattr(row, "__slots__") else _as_dict(row) for row in rows],
|
||||
"totals": totals(rows),
|
||||
"station_count": len(rows),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""구조물도 식 풀이 — 서버가 부르는 **껍데기**. 계산은 여기 없음.
|
||||
|
||||
풀이는 화면이 쓰는 `B08_Quantity_Formula.ts` 한 벌을 Node 로 돌림(판정 Ⓐ, 2026-09-13 ·
|
||||
명세 13장). 파이썬으로 다시 짜면 반올림이 1원에서 조용히 갈리므로 두 벌을 두지 않음.
|
||||
B06 `B06_Section_Server_Calc_Prebuild.py` 와 같은 본(`common_util_node_bundle`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BUNDLE = ROOT / "config" / "formula_node" / "B08_Quantity_Formula_Node.js"
|
||||
_NPM_SCRIPT = "build:formula"
|
||||
|
||||
|
||||
def evaluate_sheets(sheets: list[dict[str, Any]]) -> list[list[dict[str, Any]]] | None:
|
||||
"""장 여러 벌을 한 번에 풂 — 줄마다 `amount`(반올림 뒤)·`raw`·`error`.
|
||||
|
||||
Node 가 못 돌면 `None` — 값을 지어내지 않고 부르는 쪽이 「못 풂」으로 드러냄.
|
||||
"""
|
||||
output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"sheets": sheets})
|
||||
if not isinstance(output, dict) or not isinstance(output.get("sheets"), list):
|
||||
return None
|
||||
return output["sheets"]
|
||||
@@ -111,6 +111,7 @@ def build_handoff(
|
||||
concrete_placing_method: str | None = None,
|
||||
bench_cut_depth_m: float | None = None,
|
||||
structure_trench_water: str | None = None,
|
||||
stand_volume_class: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**."""
|
||||
table = mapping or load_mapping()
|
||||
@@ -119,7 +120,11 @@ def build_handoff(
|
||||
|
||||
methods = {key: value for key, value in (ground_methods or {}).items() if value}
|
||||
if summary_table:
|
||||
rows, misses = _earthwork_rows(summary_table, table, methods, bench_cut_depth_m)
|
||||
# 임목축적 등급 — 지장목제거 뿌리뽑기(9-21 제근)의 품 갈래(2026-09-13 판정 Ⓑ).
|
||||
variant_inputs = {"stand_volume_class": stand_volume_class}
|
||||
rows, misses = _earthwork_rows(
|
||||
summary_table, table, methods, bench_cut_depth_m, variant_inputs
|
||||
)
|
||||
work_items.extend(rows)
|
||||
unmatched.extend(misses)
|
||||
if haul_table:
|
||||
|
||||
@@ -205,6 +205,7 @@ class WorkItemMapping:
|
||||
pipe: dict[str, Any] = field(default_factory=dict)
|
||||
#: 갈래 이름 별칭 — 우리 「리핑암」 ↔ 일위대가 「파쇄암」처럼 **같은 것을 다른 이름**으로
|
||||
#: 부르는 자리. ⚠ 갈래 이름 자체를 갈지 않는다(흙깎기 매핑이 그 이름으로 서 있다).
|
||||
#: ⚠ 정본은 **별칭표 한 벌**(`common_util_aliases`)이다 — 여기는 인계본에 싣는 보기일 뿐.
|
||||
ground_aliases: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def declared_units(self) -> dict[str, str]:
|
||||
@@ -221,17 +222,30 @@ class WorkItemMapping:
|
||||
found[str(code)] = str(unit)
|
||||
return found
|
||||
|
||||
def for_earthwork(self, group: str, ground: str | None) -> dict[str, Any] | None: # noqa: D401
|
||||
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다."""
|
||||
def for_earthwork(
|
||||
self, group: str, ground: str | None, item: str | None = None
|
||||
) -> dict[str, Any] | None: # noqa: D401
|
||||
"""공종+지반유형으로 찾는다. 지반이 갈리지 않는 공종은 `ground` 없는 줄이 받는다.
|
||||
|
||||
`item` 은 **작업 갈래**(지장목제거의 「잡관목제거」) — 매핑 줄에 `item` 이 있으면 같아야 함.
|
||||
"""
|
||||
exact = [
|
||||
row
|
||||
for row in self.earthwork
|
||||
if row.get("group") == group and row.get("ground") == ground
|
||||
if row.get("group") == group
|
||||
and row.get("ground") == ground
|
||||
and ("item" not in row or row.get("item") == item)
|
||||
]
|
||||
if exact:
|
||||
return exact[0]
|
||||
# 지반을 안 가르는 공종(성토·층따기 등)은 `ground` 칸이 없는 줄로 맞춘다.
|
||||
loose = [row for row in self.earthwork if row.get("group") == group and "ground" not in row]
|
||||
loose = [
|
||||
row
|
||||
for row in self.earthwork
|
||||
if row.get("group") == group
|
||||
and "ground" not in row
|
||||
and ("item" not in row or row.get("item") == item)
|
||||
]
|
||||
return loose[0] if loose else None
|
||||
|
||||
def for_haul(self, equipment: str) -> dict[str, Any] | None:
|
||||
@@ -263,6 +277,11 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
|
||||
if target is None or not target.is_file():
|
||||
return WorkItemMapping()
|
||||
payload = json.loads(target.read_text(encoding="utf-8"))
|
||||
from common_util.common_util_aliases import load_aliases, names_view
|
||||
|
||||
# 별칭은 매핑표가 아니라 별칭표 한 벌에서 온다 — **매핑이 기대는 공종 마스터 판**의 줄만 싣는다.
|
||||
edition = str((payload.get("master") or {}).get("effective_date") or "")
|
||||
variant_aliases = [row for row in load_aliases("variant") if row["pum_edition"] == edition]
|
||||
return WorkItemMapping(
|
||||
effective_date=str(payload.get("effective_date") or ""),
|
||||
earthwork=list(payload.get("earthwork") or []),
|
||||
@@ -274,7 +293,7 @@ def load_mapping(path: Path | None = None) -> WorkItemMapping:
|
||||
concrete_placing=payload.get("concrete_placing") or {},
|
||||
pipe=payload.get("pipe") or {},
|
||||
unit_conversion=(payload.get("composite") or {}).get("unit_conversion") or {},
|
||||
ground_aliases=payload.get("ground_aliases") or {},
|
||||
ground_aliases={"aliases": names_view(variant_aliases)} if variant_aliases else {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -93,9 +93,12 @@ def _earthwork_rows(
|
||||
mapping: WorkItemMapping,
|
||||
methods: dict[str, str | None],
|
||||
bench_cut_depth_m: float | None = None,
|
||||
variant_inputs: dict[str, str | None] | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""토공집계표 줄을 내역 줄로 옮긴다.
|
||||
|
||||
`variant_inputs` — 매핑 줄이 `variant_from` 으로 가리키는 **설정값**(임목축적 등급 등).
|
||||
|
||||
⚠ 「보정량계」 같은 합계 줄은 **내역 줄이 아니다** — 빼지 않고 `in_bill: False` 로 넘긴다.
|
||||
빼 버리면 B09 가 검산할 때 합이 안 맞는 까닭을 알 수 없다.
|
||||
"""
|
||||
@@ -115,7 +118,9 @@ def _earthwork_rows(
|
||||
# ⚠ 운반은 **집계에도 오르고 운반표에도 오른다** — 내역 줄은 운반표 쪽 하나뿐이다.
|
||||
is_subtotal = group in SUBTOTAL_GROUPS or group in HAUL_SUMMARY_GROUPS
|
||||
lookup_ground, method_note = _mapping_ground(ground, methods)
|
||||
entry = mapping.for_earthwork(group, lookup_ground) if method_note == "" else None
|
||||
entry = (
|
||||
mapping.for_earthwork(group, lookup_ground, work_kind) if method_note == "" else None
|
||||
)
|
||||
code = (entry or {}).get("work_item_code")
|
||||
# ⚠ 품셈 밑수와 우리 단위가 다른 자리 — **곱하면 금액이 틀린다**(층따기 9-18).
|
||||
# 면적 값을 버리지 않고 `spec_detail` 에 남겨 되짚을 수 있게 한다.
|
||||
@@ -169,9 +174,15 @@ def _earthwork_rows(
|
||||
"spec_detail": spec_detail,
|
||||
"composite_parts": None,
|
||||
"structure_kind": None,
|
||||
# 토공·운반 줄에는 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
|
||||
"variant_axis": None,
|
||||
"variant_value": None,
|
||||
# 토공 줄은 대개 갈래 축이 없다 — 그래도 **칸은 둔다**(계약이 한 모양).
|
||||
# 매핑이 갈래를 적은 작업 갈래(잡관목제거 → 단목베기 「5m 미만」)만 값을 싣는다.
|
||||
"variant_axis": (entry or {}).get("variant_axis"),
|
||||
# 암 갈래(연암·보통암·경암)는 줄 자신의 갈래를 넘김 — 9-4·9-5 단계 합산형(축 C Ⓐ).
|
||||
"variant_value": (entry or {}).get("variant_value")
|
||||
or {**(variant_inputs or {}), "ground_class": ground}.get(
|
||||
str((entry or {}).get("variant_from") or "")
|
||||
)
|
||||
or None,
|
||||
"secondary_axes": None,
|
||||
"spec_class": None,
|
||||
"spec_class_basis": "",
|
||||
|
||||
@@ -43,14 +43,24 @@ GROUND_LABELS = {"ea_m3": "토사", "rr_m3": "리핑암", "br_m3": "발파암"}
|
||||
GROUND_KIND_OF = {"토사": "soil", "리핑암": "ripping_rock", "발파암": "blasting_rock"}
|
||||
|
||||
|
||||
def _factor_of(ground: str) -> float | None:
|
||||
"""그 갈래의 다짐 환산계수 `C`. 모르면 `None`(받는 쪽이 환산했는지 되짚는 데 쓴다)."""
|
||||
#: 계수 묶음의 모양 — `{지반유형: {"compacted": C}}`. 안 주면 정본 기본값이 선다.
|
||||
Factors = dict[str, dict[str, float]]
|
||||
|
||||
|
||||
def _factor_of(ground: str, factors: Factors | None = None) -> float | None:
|
||||
"""그 갈래의 다짐 환산계수 `C`. 모르면 `None`(받는 쪽이 환산했는지 되짚는 데 쓴다).
|
||||
|
||||
`factors` 는 프로젝트가 고른 계수다(`earthwork_conversion_factors`) — 안 주면 정본.
|
||||
"""
|
||||
kind = GROUND_KIND_OF.get(ground)
|
||||
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None
|
||||
table = factors or EARTHWORK_CONVERSION_FACTORS
|
||||
entry = table.get(kind) if kind else None
|
||||
return float(entry["compacted"]) if entry else None
|
||||
|
||||
|
||||
def natural_m3(compacted_volume_m3: float, ground: str) -> float | None:
|
||||
def natural_m3(
|
||||
compacted_volume_m3: float, ground: str, factors: Factors | None = None
|
||||
) -> float | None:
|
||||
"""**다짐상태 → 자연상태**(÷ C). 내역서에 오르는 수량은 자연상태다.
|
||||
|
||||
근거 — `config_system_design` 5-4-3 에 이미 적혀 있던 문장이다.
|
||||
@@ -69,7 +79,8 @@ def natural_m3(compacted_volume_m3: float, ground: str) -> float | None:
|
||||
⚠ 갈래를 모르면 `None` 이다 — 토사 계수로 눅이면 근거 없이 금액이 움직인다.
|
||||
"""
|
||||
kind = GROUND_KIND_OF.get(ground)
|
||||
entry = EARTHWORK_CONVERSION_FACTORS.get(kind) if kind else None
|
||||
table = factors or EARTHWORK_CONVERSION_FACTORS
|
||||
entry = table.get(kind) if kind else None
|
||||
if not entry:
|
||||
return None
|
||||
factor = float(entry["compacted"])
|
||||
@@ -189,8 +200,11 @@ def summarize(legs: Iterable[HaulLeg]) -> list[HaulSummaryRow]:
|
||||
)
|
||||
|
||||
|
||||
def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다."""
|
||||
def build_table(plan: dict[str, Any] | None, factors: Factors | None = None) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양. 내역 줄과 근거 줄을 함께 낸다.
|
||||
|
||||
`factors` 는 프로젝트가 고른 토량환산계수다 — 다짐 → 자연 되돌리기가 이 값에 걸린다.
|
||||
"""
|
||||
legs = _legs_of(plan or {})
|
||||
rows = summarize(legs)
|
||||
return {
|
||||
@@ -203,9 +217,9 @@ def build_table(plan: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"volume_m3": row.volume_m3,
|
||||
"volume_basis": "compacted",
|
||||
# 내역서에 오르는 수량 = **자연상태**(÷C). 갈래를 모르면 `None`.
|
||||
"natural_m3": natural_m3(row.volume_m3, row.ground),
|
||||
"natural_m3": natural_m3(row.volume_m3, row.ground, factors),
|
||||
"natural_volume_basis": "natural",
|
||||
"conversion_c": _factor_of(row.ground),
|
||||
"conversion_c": _factor_of(row.ground, factors),
|
||||
"average_distance_m": row.average_distance_m,
|
||||
"work_m3m": row.work_m3m,
|
||||
"legs": row.legs,
|
||||
|
||||
@@ -151,6 +151,7 @@ class MaterialRow:
|
||||
|
||||
name: str
|
||||
unit: str
|
||||
spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ)
|
||||
net_amount: float = 0.0 # 순수량 — 할증 전
|
||||
surcharge_pct: float | None = None # None = 미확보
|
||||
supply: str = SUPPLY_UNKNOWN
|
||||
@@ -159,6 +160,11 @@ class MaterialRow:
|
||||
basis: str = ""
|
||||
sources: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def supply_key(self) -> str:
|
||||
"""관급구분 설정(`material_supply`)을 찾는 열쇠 — 이름 + 규격."""
|
||||
return f"{self.name} {self.spec}".strip()
|
||||
|
||||
@property
|
||||
def total_amount(self) -> float:
|
||||
"""합계 = 순수량 × (1 + 할증률). 미확보면 **순수량 그대로** 두고 비고로 알린다."""
|
||||
@@ -255,11 +261,36 @@ def surcharge_lookup_name(name: str, concrete_placing_method: str | None) -> tup
|
||||
return name, ""
|
||||
|
||||
|
||||
def _add(rows: dict[tuple[str, str, str], MaterialRow], item: dict[str, Any]) -> MaterialRow:
|
||||
"""(이름·규격·단위)가 같은 줄에 더한다 — 규격이 다르면 다른 자재다(돌 종류·강도)."""
|
||||
name = str(item.get("name") or "").strip()
|
||||
spec = str(item.get("spec") or "").strip()
|
||||
unit = str(item.get("unit") or "").strip()
|
||||
row = rows.setdefault((name, spec, unit), MaterialRow(name=name, unit=unit, spec=spec))
|
||||
row.net_amount += float(item.get("amount") or 0.0)
|
||||
if item.get("surcharge_included"):
|
||||
row.surcharge_included = True
|
||||
return row
|
||||
|
||||
|
||||
def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any:
|
||||
"""그 줄의 관급구분 설정 — 이름+규격이 먼저, 없으면 옛 열쇠.
|
||||
|
||||
⚠ 옛 열쇠 둘을 받는다 — 이름만(규격이 갈리기 전 「채움콘크리트」) · 규격만(돌 줄이
|
||||
종류 이름으로 서던 때 「야면석·호박돌」, 2026-09-13 걷음).
|
||||
안 받으면 사용자가 정한 값이 사라진다.
|
||||
"""
|
||||
for key in (row.supply_key, row.name, row.spec):
|
||||
if key and key in supply:
|
||||
return supply[key]
|
||||
return None
|
||||
|
||||
|
||||
def _collect(
|
||||
unit_quantity_table: dict[str, Any],
|
||||
) -> tuple[dict[tuple[str, str], MaterialRow], dict[str, int]]:
|
||||
) -> tuple[dict[tuple[str, str, str], MaterialRow], dict[str, int]]:
|
||||
"""`destination == "material"` 만 모은다. 나머지는 세어서 보인다."""
|
||||
rows: dict[tuple[str, str], MaterialRow] = {}
|
||||
rows: dict[tuple[str, str, str], MaterialRow] = {}
|
||||
skipped: dict[str, int] = {}
|
||||
for structure in unit_quantity_table.get("structures", []):
|
||||
label = str(structure.get("name") or structure.get("type_id") or "")
|
||||
@@ -268,12 +299,7 @@ def _collect(
|
||||
if destination != ACCEPTED_DESTINATION:
|
||||
skipped[destination] = skipped.get(destination, 0) + 1
|
||||
continue
|
||||
name = str(component.get("name") or "").strip()
|
||||
unit = str(component.get("unit") or "").strip()
|
||||
row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit))
|
||||
row.net_amount += float(component.get("amount") or 0.0)
|
||||
if component.get("surcharge_included"):
|
||||
row.surcharge_included = True
|
||||
row = _add(rows, component)
|
||||
if label and label not in row.sources:
|
||||
row.sources.append(label)
|
||||
return rows, skipped
|
||||
@@ -298,12 +324,7 @@ def build_table(
|
||||
for item in extra_materials:
|
||||
if str(item.get("destination") or ACCEPTED_DESTINATION) != ACCEPTED_DESTINATION:
|
||||
continue
|
||||
name = str(item.get("name") or "").strip()
|
||||
unit = str(item.get("unit") or "").strip()
|
||||
row = rows.setdefault((name, unit), MaterialRow(name=name, unit=unit))
|
||||
row.net_amount += float(item.get("amount") or 0.0)
|
||||
if item.get("surcharge_included"):
|
||||
row.surcharge_included = True
|
||||
row = _add(rows, item)
|
||||
source = str(item.get("source") or "")
|
||||
if source and source not in row.sources:
|
||||
row.sources.append(source)
|
||||
@@ -312,13 +333,14 @@ def build_table(
|
||||
missing_rate: list[str] = []
|
||||
missing_supply: list[str] = []
|
||||
missing_install_by: list[str] = []
|
||||
for (name, _unit), row in rows.items():
|
||||
row.supply, row.install_by = _supply_of(supply.get(name))
|
||||
for row in rows.values():
|
||||
name = row.name
|
||||
row.supply, row.install_by = _supply_of(_supply_setting(supply, row))
|
||||
if row.supply == SUPPLY_UNKNOWN:
|
||||
missing_supply.append(name)
|
||||
missing_supply.append(row.supply_key)
|
||||
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
|
||||
if row.supply == SUPPLY_OWNER and row.install_by is None:
|
||||
missing_install_by.append(name)
|
||||
missing_install_by.append(row.supply_key)
|
||||
if row.surcharge_included:
|
||||
continue
|
||||
lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method)
|
||||
@@ -328,7 +350,7 @@ def build_table(
|
||||
if rate is None:
|
||||
missing_rate.append(name)
|
||||
|
||||
ordered = sorted(rows.values(), key=lambda item: (item.name, item.unit))
|
||||
ordered = sorted(rows.values(), key=lambda item: (item.name, item.spec, item.unit))
|
||||
return {
|
||||
"columns": [
|
||||
"자재명",
|
||||
@@ -343,6 +365,9 @@ def build_table(
|
||||
"rows": [
|
||||
{
|
||||
"name": row.name,
|
||||
"spec": row.spec,
|
||||
# 관급구분을 고르는 열쇠 — 화면이 이 이름으로 설정에 적는다.
|
||||
"supply_key": row.supply_key,
|
||||
"unit": row.unit,
|
||||
"net_amount": row.net_amount,
|
||||
"surcharge_pct": row.surcharge_pct,
|
||||
|
||||
@@ -134,9 +134,8 @@ def preparation_rows(
|
||||
"대상 면적에 **소단면이 포함**됨(별표2 타.(1) 「노출되는 면은 전체면적 녹화」 — "
|
||||
"원문이 가르지 않고 실무도 한 덩이로 셈). "
|
||||
"토공집계의 「지장목제거」로 이미 섬 — 여기서 또 세우면 이중계상. "
|
||||
"⚠ 다만 **공종 미확정** — 품셈 4장이 벌목을 목적별로 갈라(수확베기·단목베기·"
|
||||
"위험목 베기) 임도 지장목이 어디에 붙는지 원본이 말하지 않음."
|
||||
" 지금은 공종코드 없이 감."
|
||||
"공종은 토공집계 줄이 가짐 — 잡관목제거는 품셈 4-2-2 단목베기(FP-04-02-02)"
|
||||
" 「5m 미만」, 뿌리뽑기는 9-21 제근(FP-09-21)(2026-09-13 판정)."
|
||||
),
|
||||
"work_item_code": None,
|
||||
},
|
||||
@@ -259,6 +258,12 @@ ROOT_REMOVAL_BASIS = (
|
||||
"밑수는 **면적 축**(사용자 확정 5차 6번) — 산림품셈 9-21 에 밑수 표기가 없어"
|
||||
" ⚠ **교차 참조**: 건설공사 표준품셈 **3-9-2 뿌리뽑기 「1,000㎡당」**을 빌려 씀"
|
||||
)
|
||||
#: ⭐ 2026-09-13 판정 Ⓑ — 제근은 **토공 줄**이다(지식DB `토공_수량.md:30` 「뿌리다듬기·적재·제근 |
|
||||
#: 9-20~21 | 벌개제근 연동」). 실무 서식(영월 1.9.1 뿌리뽑기)대로 토공집계가 세고 여기는 참조.
|
||||
ROOT_COUNTED_IN_SUMMARY = (
|
||||
"토공집계 「지장목제거 · 뿌리뽑기」가 품셈 9-21 제근(FP-09-21)으로 셈 — 같은 면적·같은 작업이라"
|
||||
" 여기서 또 세우면 이중계상(2026-09-13 판정)"
|
||||
)
|
||||
ROOT_REMOVAL_CLASS_MISSING = (
|
||||
"임목축적 등급이 아직 입력되지 않았습니다 — 품셈 9-21 [주]① 이 소림(30~60㎥/㏊)·"
|
||||
"중림(60~90)·밀림(90 이상)으로 가름. ⚠ 본수가 아니라 **축적**이고, 산림조사부·영림계획에서"
|
||||
@@ -273,7 +278,11 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -
|
||||
"""
|
||||
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
|
||||
picked = str(stand_volume_class or "").strip()
|
||||
reasons = [ROOT_REMOVAL_BASIS, "대상 면적은 지장목제거와 같은 자리(벌개제근 연동)"]
|
||||
reasons = [
|
||||
ROOT_COUNTED_IN_SUMMARY,
|
||||
ROOT_REMOVAL_BASIS,
|
||||
"대상 면적은 지장목제거와 같은 자리(벌개제근 연동)",
|
||||
]
|
||||
if picked in STAND_VOLUME_CLASSES:
|
||||
reasons.append(f"임목축적 등급 「{picked}」 — 품셈 9-21 [주]① 이 품을 그 축으로 가름")
|
||||
else:
|
||||
@@ -285,10 +294,12 @@ def _root_removal_row(slope: dict[str, float], stand_volume_class: str | None) -
|
||||
"item": "제근·뿌리다듬기",
|
||||
"unit": "㎡",
|
||||
"amount": area if area > 0 else None,
|
||||
"status": STATUS_READY if area > 0 else STATUS_PENDING,
|
||||
# ⭐ 2026-09-13 판정 Ⓑ — 셈은 토공집계 「지장목제거 · 뿌리뽑기」(FP-09-21)가 한다.
|
||||
# 여기는 **보이되 안 실린다**(같은 면적 · 같은 작업 — 또 세면 이중계상).
|
||||
"status": STATUS_COUNTED_ELSEWHERE if area > 0 else STATUS_PENDING,
|
||||
"reason": " · ".join(reasons),
|
||||
"reference_amount": area,
|
||||
"work_item_code": "FP-09-21",
|
||||
"work_item_code": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""구조물도 양식 **라이브러리** — 3단(개인·회사·프로그램) 목록과 프로젝트로 **가져오기** (PLAN 4장).
|
||||
|
||||
⛔ 표를 그릴 때 라이브러리를 읽지 않음 — 가져온 양식은 **프로젝트 작업본에 박히고**
|
||||
(`{프로젝트}/B08_Quantity/library/<code>.json`) 표는 그것만 읽음. 3단은 가져오기 고르개에서만 돎.
|
||||
여는 사람마다 값이 갈리지 않게 하려는 것(브레인 판정 Ⓑ — 개인 = 로그인한 사람).
|
||||
⚠ 아직 아무것도 안 가져온 종류는 프로그램 기본을 읽음 — 보는 사람과 무관한 한 벌.
|
||||
⚠ 저장은 **목록**(항목마다 파일 한 개 · 코드 `AX-ST-<8자리 16진>`, 명세 2장 ②) —
|
||||
지금은 종류당 하나만 씀(판정 Ⓐ). 고르기 칸은 뒤에 붙임.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import TEMPLATE_DIR
|
||||
from config.config_system import STORAGE_BASE_DIR
|
||||
|
||||
#: 단 이름 — 고르개에 이 차례로 보임(가까운 것부터).
|
||||
TIERS = ("personal", "company", "program")
|
||||
#: 항목 코드 — 파일 이름이 되므로 모양을 먼저 봄(경로 벗어남 막이).
|
||||
CODE_PATTERN = re.compile(r"^AX-ST-[0-9a-f]{8}$")
|
||||
LIBRARY_SUBDIR = "library"
|
||||
|
||||
|
||||
def tier_dirs(company_id: Any, user_id: Any) -> dict[str, Path]:
|
||||
"""로그인한 사람의 3단 폴더. 회사가 없으면(시스템 관리자) 개인·회사 단은 없음."""
|
||||
dirs: dict[str, Path] = {}
|
||||
if company_id is not None:
|
||||
company = Path(STORAGE_BASE_DIR).resolve() / str(company_id)
|
||||
if user_id is not None:
|
||||
dirs["personal"] = company / str(user_id) / LIBRARY_SUBDIR
|
||||
dirs["company"] = company / LIBRARY_SUBDIR
|
||||
dirs["program"] = TEMPLATE_DIR
|
||||
return dirs
|
||||
|
||||
|
||||
def _items(folder: Path) -> list[dict[str, Any]]:
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))]
|
||||
|
||||
|
||||
def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
|
||||
"""가져오기 고르개 — 단마다 그 종류의 항목(단·코드·이름)."""
|
||||
return [
|
||||
{"tier": tier, "code": item.get("code"), "name": item.get("name"), "type_id": type_id}
|
||||
for tier in TIERS
|
||||
if tier in dirs
|
||||
for item in _items(dirs[tier])
|
||||
if item.get("type_id") == type_id and item.get("code")
|
||||
]
|
||||
|
||||
|
||||
def find_item(dirs: dict[str, Path], tier: str, code: str) -> dict[str, Any] | None:
|
||||
folder = dirs.get(tier)
|
||||
if folder is None:
|
||||
return None
|
||||
return next((item for item in _items(folder) if item.get("code") == code), None)
|
||||
|
||||
|
||||
def project_library_dir(project_root: str | Path) -> Path:
|
||||
return Path(project_root) / "B08_Quantity" / LIBRARY_SUBDIR
|
||||
|
||||
|
||||
def project_templates(project_root: str | Path | None) -> dict[str, dict[str, Any]]:
|
||||
"""프로젝트에 박힌 양식 — 종류(type_id)별 하나. **표를 그릴 때 읽는 유일한 양식 자리.**"""
|
||||
if not project_root:
|
||||
return {}
|
||||
return {
|
||||
str(item["type_id"]): item
|
||||
for item in _items(project_library_dir(project_root))
|
||||
if item.get("type_id")
|
||||
}
|
||||
|
||||
|
||||
def available_templates(project_root: str | Path | None) -> list[dict[str, Any]]:
|
||||
"""하위 일위대가(`B-AX-ST-*`)를 찾을 양식들 — 프로그램 기본 뒤에 **프로젝트에 박힌 것**(이김).
|
||||
|
||||
⛔ 개인·회사 단은 안 넣음 — 표를 그릴 때 라이브러리를 매번 읽지 않음(판정 Ⓑ).
|
||||
"""
|
||||
return [*_items(TEMPLATE_DIR), *project_templates(project_root).values()]
|
||||
|
||||
|
||||
def import_item(project_root: str | Path, item: dict[str, Any], tier: str) -> None:
|
||||
"""고른 항목을 프로젝트 작업본에 박음 — 같은 종류의 옛 것은 지움(종류당 하나).
|
||||
|
||||
⚠ 새 것을 먼저 쓰고 옛 것을 지움 — 쓰다 실패하면 옛 양식이 남아 조용히 기본으로 안 떨어짐.
|
||||
"""
|
||||
code = str(item.get("code") or "")
|
||||
if not CODE_PATTERN.match(code):
|
||||
raise ValueError(f"항목 코드 모양이 아님: {code!r}")
|
||||
folder = project_library_dir(project_root)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
body = {**item, "imported_from": tier}
|
||||
target = folder / f"{code}.json"
|
||||
target.write_text(json.dumps(body, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
for path in folder.glob("*.json"):
|
||||
if path == target:
|
||||
continue
|
||||
if json.loads(path.read_text(encoding="utf-8")).get("type_id") == item.get("type_id"):
|
||||
path.unlink()
|
||||
|
||||
|
||||
def _write(folder: Path, item: dict[str, Any]) -> None:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(item, ensure_ascii=False, indent=2)
|
||||
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, Any] | None) -> str:
|
||||
"""[내 라이브러리에 저장] — 양식 + 프로젝트에서 고친 식을 **개인 단에 한 벌**로 씀. 코드.
|
||||
|
||||
⚠ 반대 방향(작업본 → 개인 단)이라 프로젝트는 안 바꿈(브레인 판정 Ⓑ).
|
||||
⚠ 개인 단에 같은 종류가 있으면 **그 코드로 덮어씀** — 지금은 종류당 하나(판정 Ⓐ).
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
|
||||
|
||||
type_id = template.get("type_id")
|
||||
same = [item for item in _items(folder) if item.get("type_id") == type_id]
|
||||
code = str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
|
||||
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
|
||||
dropped = {"default_formula", "default_rounding"}
|
||||
rows = [
|
||||
{**{k: v for k, v in row.items() if k not in dropped}, "source": "library"}
|
||||
for row in overridden_rows(template, overrides)
|
||||
]
|
||||
item = {k: v for k, v in template.items() if k != "imported_from"}
|
||||
_write(folder, {**item, "code": code, "library_tier": "personal", "rows": rows})
|
||||
return code
|
||||
|
||||
|
||||
def delete_personal(folder: Path, type_id: str) -> int:
|
||||
"""[내 것 지우기] — 개인 단의 그 종류만 지움. 프로젝트 작업본은 안 바꿈. 지운 수."""
|
||||
count = 0
|
||||
for path in folder.glob("*.json") if folder.is_dir() else []:
|
||||
if json.loads(path.read_text(encoding="utf-8")).get("type_id") == type_id:
|
||||
path.unlink()
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def pin_program_templates(project_root: str | Path) -> int:
|
||||
"""[확정] 때 — 아직 안 박힌 종류는 **그 시점 프로그램 기본**을 박음. 박은 수.
|
||||
|
||||
확정은 정본을 굳히는 자리라, 뒤에 프로그램 기본을 고쳐도 확정한 값이 안 흔들려야 함
|
||||
(브레인 판정). 확정 전(GET 만 한 프로젝트)은 기본을 그대로 읽음 — 정본이 아님.
|
||||
"""
|
||||
pinned = project_templates(project_root)
|
||||
count = 0
|
||||
for item in _items(TEMPLATE_DIR):
|
||||
if item.get("type_id") and item.get("code") and item["type_id"] not in pinned:
|
||||
import_item(project_root, item, "program")
|
||||
count += 1
|
||||
return count
|
||||
+54
-6
@@ -20,6 +20,10 @@
|
||||
|
||||
⚠ **단위당 값**은 성분 수량을 `billing_quantity` 로 나눈 것이다. 실무 시트 머리의 `m당` ·
|
||||
`개소당` · `㎡당` 이 그 단위이고, **구조물마다 다르다**(통일하지 않음 — 4-1).
|
||||
|
||||
⚠ **2026-09-13 B07 에서 이관**(PLAN 3장 · 사용자 확정 ③ 「구조물도는 B08 로」).
|
||||
옛 자리 `B07_DesignDetail_Engine_Standard_Sheet.py`.
|
||||
B07 그림 파일을 부르지 않게 기울기 판정 대상도 함께 옮김.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +31,8 @@ from __future__ import annotations
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import BACK_LENGTH_KEYS, face_slope_ratio
|
||||
|
||||
#: 장을 가르지 **않는** 제원 칸 — 개소마다 다를 뿐 그림·단위수량을 안 바꾼다.
|
||||
#: 여기 없는 칸은 전부 장 나눔에 들어간다(모르는 칸을 빠뜨려 두 장이 한 장으로 합쳐지는 것보다,
|
||||
#: 장이 하나 더 서는 쪽이 안전하다 — 합쳐지면 값이 조용히 틀린다).
|
||||
@@ -38,6 +44,16 @@ PER_PLACE_OPTION_KEYS: frozenset[str] = frozenset(
|
||||
"station_m",
|
||||
"side",
|
||||
"length_m",
|
||||
# 구간 규약(C군) — 기준 측점에서 앞뒤로 얼마나 걸치나. **자리**이지 제원이 아니다
|
||||
# (2026-09-13 실측: 제원이 같은 찰쌓기 두 기가 이 칸 때문에 두 장으로 갈렸음).
|
||||
"before_m",
|
||||
"after_m",
|
||||
"inlet_revet_before_m",
|
||||
"inlet_revet_after_m",
|
||||
"outlet_revet_before_m",
|
||||
"outlet_revet_after_m",
|
||||
"inlet_basin_before_m",
|
||||
"inlet_basin_after_m",
|
||||
"note",
|
||||
"memo",
|
||||
"label",
|
||||
@@ -76,13 +92,40 @@ def sheet_key(structure: dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
def _slope_or_none(structure: dict[str, Any]) -> float | None:
|
||||
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`. 판정은 그림 모듈이 가진 한 벌."""
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
|
||||
SLOPE_TYPE_IDS,
|
||||
slope_of,
|
||||
#: **전면 기울기 판정** 대상 — 그림 대상(`FIGURE_TYPE_IDS`)과 **다르다**.
|
||||
#: 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다(품셈 13-6 「1:0.3 이상」).
|
||||
#: 한 목록으로 묶어 두었더니 큰돌쌓기를 그림에서 뺄 때 **장 제목의 「1:0.3」까지
|
||||
#: 사라졌다**(2026-09-09 화면 실측에서 잡음).
|
||||
SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"})
|
||||
|
||||
|
||||
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
|
||||
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다.
|
||||
|
||||
메/찰은 종류에서 온다. 큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면
|
||||
찰쌓기로 본다 — 품셈 13-6 은 둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다.
|
||||
"""
|
||||
options = sheet.get("options") or {}
|
||||
type_id = str(sheet.get("type_id") or "")
|
||||
if type_id == "masonry_dry":
|
||||
wet = False
|
||||
elif type_id == "boulder_masonry":
|
||||
wet = options.get("bond") != "메쌓기"
|
||||
else:
|
||||
wet = True
|
||||
return face_slope_ratio(
|
||||
options,
|
||||
wet=wet,
|
||||
height_m=float(sheet.get("height_m") or 0.0),
|
||||
# ⚠ 표를 만든 그 판정을 그대로 넘긴다 — 여기서 다시 가르면 근거를 못 받아
|
||||
# 종전값으로 떨어지고 **표와 갈린다**(2026-09-09 실측).
|
||||
face=sheet.get("face"),
|
||||
face_reason=str(sheet.get("face_reason") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _slope_or_none(structure: dict[str, Any]) -> float | None:
|
||||
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`."""
|
||||
# ⚠ **그림 대상 목록을 쓰지 않는다** — 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다.
|
||||
if str(structure.get("type_id") or "") not in SLOPE_TYPE_IDS:
|
||||
return None
|
||||
@@ -101,7 +144,8 @@ def sheet_title(structure: dict[str, Any]) -> str:
|
||||
slope = _slope_or_none(structure)
|
||||
if slope is not None:
|
||||
parts.append(f"1:{slope:g}")
|
||||
back = options.get("back_len_cm") or options.get("stone_back_length_cm")
|
||||
# 옛 저장분 키까지 읽는 차례는 전개의 한 벌(`BACK_LENGTH_KEYS`) — 키 이름을 다시 적지 않음.
|
||||
back = next((options[key] for key in BACK_LENGTH_KEYS if options.get(key)), None)
|
||||
if back:
|
||||
parts.append(f"뒷길이 {int(back)}㎝")
|
||||
kind = options.get("stone_kind")
|
||||
@@ -127,11 +171,15 @@ def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
{
|
||||
"no": index,
|
||||
"name": component.get("name") or "",
|
||||
# 실무 시트 머리의 「규격」 칸(210 · Ø50 등) — 전개가 이미 싣고 옴(2026-09-13).
|
||||
"spec": component.get("spec") or "",
|
||||
# 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다.
|
||||
"basis": component.get("basis") or "",
|
||||
"unit_amount": _unit_amount(amount, quantity),
|
||||
"amount": amount,
|
||||
"unit": component.get("unit") or "",
|
||||
# 갈 곳 — 이중계상 경계를 줄마다 보임(명세 13장 Ⓑ). 전개가 이미 싣고 옴.
|
||||
"destination": component.get("destination") or "",
|
||||
# 값이 식에서 나왔나(derived) 관측 원단위표에서 왔나(observed) — 되짚기용.
|
||||
"basis_kind": component.get("basis_kind") or "",
|
||||
"source": component.get("source") or "",
|
||||
+9
@@ -16,6 +16,8 @@
|
||||
⚠ **고치면 장이 갈릴 수 있다** — 그 조합 전부에 같은 값을 넣으므로 장은 통째로 옮겨 가고
|
||||
쪼개지지 않는다. 한 개소만 다르게 하려면 그 구조물을 따로 고쳐야 하고, 그때 새 조합이
|
||||
되어 장이 하나 는다(PLAN 4-5b).
|
||||
|
||||
⚠ **2026-09-13 B07 에서 이관**(PLAN 3장). 옛 자리 `B07_DesignDetail_Engine_Standard_Edit.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -35,6 +37,7 @@ EDITABLE_KEYS: tuple[str, ...] = (
|
||||
"fill_concrete_mpa",
|
||||
"thickness_top_m",
|
||||
"thickness_bottom_m",
|
||||
"blinding_concrete",
|
||||
)
|
||||
|
||||
#: 기초 갈래 — 정본 xls 탭 제목 그대로(`04.구조도(기슭막이).xls`).
|
||||
@@ -59,6 +62,7 @@ FIELD_LABELS: dict[str, str] = {
|
||||
"fill_concrete_mpa": "채움 강도",
|
||||
"thickness_top_m": "상부 두께",
|
||||
"thickness_bottom_m": "하부 두께",
|
||||
"blinding_concrete": "버림 콘크리트",
|
||||
}
|
||||
|
||||
#: 야면석 계수를 어느 열에서 읽나 — 확정 ⑨ 「품셈 열이 기본, 사용자가 고를 수 있게」.
|
||||
@@ -68,6 +72,10 @@ STONE_COEFF_CHOICES: tuple[str, ...] = ("품셈", "실무 관행")
|
||||
#: 180 은 국가기준 하한(돌쌓기 전용), 210 은 콘크리트 구조물 몸체 쪽 기준.
|
||||
FILL_CONCRETE_CHOICES: tuple[str, ...] = ("180", "210")
|
||||
|
||||
#: 버림 콘크리트 넣음/안 넣음 — 확정 ⑭ 「기본은 넣고, 빼고 싶으면 뺀다」(두께는 KDS 100㎜).
|
||||
#: ⚠ 문구는 B08 `wants_blinding` 이 「빼기」로 읽는 말과 같아야 한다 — 다르면 골라도 안 빠진다.
|
||||
BLINDING_CHOICES: tuple[str, ...] = ("넣음", "안 넣음")
|
||||
|
||||
|
||||
def _clean_slope(value: Any) -> tuple[float | None, str | None]:
|
||||
"""전면 기울기 — `(값, 안내)`. 비면 `(None, None)` 이고 그것이 「자동」의 뜻이다."""
|
||||
@@ -134,6 +142,7 @@ def clean_spec(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
for key, choices in (
|
||||
("stone_coeff_basis", STONE_COEFF_CHOICES),
|
||||
("fill_concrete_mpa", FILL_CONCRETE_CHOICES),
|
||||
("blinding_concrete", BLINDING_CHOICES),
|
||||
):
|
||||
if key not in spec:
|
||||
continue
|
||||
@@ -0,0 +1,399 @@
|
||||
"""구조물도 **양식형 항목** 읽기 — 양식 파일 + 구조물 제원 → 식 풀이기에 넘길 장 한 벌.
|
||||
|
||||
양식은 `resources/library_structure/<type_id>.json`(4장 프로그램 기본 자리 · 명세 13장 식 칸 계약).
|
||||
풀이는 여기 없음 — `B08_Quantity_Engine_Formula.evaluate_sheets`(TS 한 벌을 Node 로)가 풂.
|
||||
|
||||
⚠ 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않음 — 제원은 `vars` 로 들어가고 같은 식이
|
||||
수량을 다시 냄(명세 16장 「한 조합 + 규격별 수량표」).
|
||||
⚠ 사용자가 고친 식(PLAN 3장 ⑤)은 **양식 + 프로젝트 단위** — 산출 조건 `quantity` 구획의
|
||||
`structure_formula_overrides` = `{양식 type_id: {차례: {"formula": 식}}}`. 고친 줄은 출처 `user`.
|
||||
장 이름(제원 조합)에 묶지 않음 — 높이를 고치면 식이 사라짐. 제원별로 달리 쓰려면 `when` 칸.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TEMPLATE_DIR = ROOT / "resources" / "library_structure"
|
||||
|
||||
#: 사용자가 고친 식이 사는 칸 — B08 산출 조건 구획(`project_settings.json` 의 `quantity`).
|
||||
OVERRIDES_KEY = "structure_formula_overrides"
|
||||
|
||||
|
||||
def load_template(type_id: str) -> dict[str, Any] | None:
|
||||
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬."""
|
||||
path = TEMPLATE_DIR / f"{type_id}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def template_of(type_id: str, templates: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""프로젝트에 박힌 양식(4장 가져오기) → 없으면 프로그램 기본.
|
||||
|
||||
⛔ 개인·회사 단은 여기서 안 읽음 — 가져온 것만 `templates` 로 옴(여는 사람마다 안 갈리게).
|
||||
"""
|
||||
return (templates or {}).get(type_id) or load_template(type_id)
|
||||
|
||||
|
||||
def _typed(value: Any, default: Any) -> Any:
|
||||
"""제원 값을 양식 기본값과 같은 꼴로 — 수 칸은 수, 글 칸은 글."""
|
||||
if isinstance(default, (int, float)) and not isinstance(default, bool):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return str(value)
|
||||
|
||||
|
||||
def template_vars(
|
||||
template: dict[str, Any],
|
||||
structure: dict[str, Any],
|
||||
judged_slope: float,
|
||||
settings: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""양식이 적은 제원 칸을 구조물·산출 조건에서 채움. 빈 칸은 양식 기본값.
|
||||
|
||||
⚠ 전면 기울기는 **전개가 판정한 값**을 받음 — 표준경사 판정(성절토·직고)은 식 언어 밖이라
|
||||
판정 한 벌(`face_slope_ratio`)을 그대로 씀.
|
||||
"""
|
||||
options = structure.get("options") or {}
|
||||
values: dict[str, Any] = {}
|
||||
for name, spec in (template.get("vars") or {}).items():
|
||||
default = spec.get("default")
|
||||
source = spec.get("source")
|
||||
if source == "judged_face_slope":
|
||||
values[name] = float(judged_slope)
|
||||
continue
|
||||
if source:
|
||||
values[name] = float(structure.get(source) or 0.0)
|
||||
continue
|
||||
if "option" in spec:
|
||||
raw = options.get(spec["option"])
|
||||
else:
|
||||
raw = (settings or {}).get(spec.get("setting"))
|
||||
values[name] = default if raw in (None, "") else _typed(raw, default)
|
||||
return values
|
||||
|
||||
|
||||
#: 반올림 갈래 — 엑셀 대응(명세 13장 대응표). 사유·근거 문구에 엑셀 이름으로 보임.
|
||||
ROUNDING_WORDS = {
|
||||
"none": "안 함",
|
||||
"floor": "INT",
|
||||
"trunc": "ROUNDDOWN",
|
||||
"round": "ROUND",
|
||||
"ceil_away": "ROUNDUP",
|
||||
"ceil": "위로(엑셀 없음)",
|
||||
"round_half_even": "짝수 반올림(엑셀 없음)",
|
||||
}
|
||||
|
||||
|
||||
def rounding_key(rounding: dict[str, Any] | None) -> tuple[str, int]:
|
||||
"""같은 반올림인지 — 「안 함」은 자리수와 무관하게 하나."""
|
||||
mode = str((rounding or {}).get("mode") or "none")
|
||||
return (mode, 0) if mode == "none" else (mode, int((rounding or {}).get("digits") or 0))
|
||||
|
||||
|
||||
def _edit_of(row: dict[str, Any], edit: dict[str, Any]) -> dict[str, Any]:
|
||||
"""저장된 고침 한 줄에서 **양식과 다른 칸만** — 식·반올림. 같으면 뺌(「고친 적 없음」)."""
|
||||
changes: dict[str, Any] = {}
|
||||
formula = str(edit.get("formula") or "").strip()
|
||||
if formula and formula != row.get("formula"):
|
||||
changes["formula"] = formula
|
||||
rounding = edit.get("rounding")
|
||||
if rounding and rounding_key(rounding) != rounding_key(row.get("rounding")):
|
||||
mode, digits = rounding_key(rounding)
|
||||
changes["rounding"] = {"mode": mode, "digits": digits}
|
||||
return changes
|
||||
|
||||
|
||||
def overridden_rows(
|
||||
template: dict[str, Any], overrides: dict[str, Any] | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""양식 줄에 **사용자가 고친 식·반올림**을 얹음 — 고친 줄은 출처 `user`, 원래 값은 따로."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for row in template.get("rows") or []:
|
||||
changes = _edit_of(row, (overrides or {}).get(str(row["seq"])) or {})
|
||||
if changes:
|
||||
row = {
|
||||
**row,
|
||||
**changes,
|
||||
"source": "user",
|
||||
"default_formula": row.get("formula"),
|
||||
"default_rounding": row.get("rounding"),
|
||||
}
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def template_sheet(
|
||||
template: dict[str, Any], values: dict[str, Any], overrides: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""식 풀이기가 받는 장 한 벌 — 줄·표는 양식 그대로(고친 식은 얹음), 제원만 끼움."""
|
||||
return {
|
||||
"rows": overridden_rows(template, overrides),
|
||||
"vars": values,
|
||||
"tables": template.get("tables") or {},
|
||||
}
|
||||
|
||||
|
||||
#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례).
|
||||
_PER_LENGTH_UNITS = frozenset({"m"})
|
||||
|
||||
|
||||
def _library_rows(
|
||||
body: dict[str, Any], solved: list[dict[str, Any]], billing: float
|
||||
) -> list[dict[str, Any]]:
|
||||
"""풀이 결과를 구조물도 줄 모양으로 — 식·설명·반올림·갈 곳·안 섬·출처까지 실음(명세 13장)."""
|
||||
by_seq = {row["seq"]: row for row in body.get("rows") or []}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for result in solved:
|
||||
source = by_seq.get(result["seq"]) or {}
|
||||
unit_amount = float(result["amount"]) if result.get("amount") is not None else None
|
||||
rows.append(
|
||||
{
|
||||
"no": result["seq"],
|
||||
"name": result["name"],
|
||||
"spec": result.get("spec") or "",
|
||||
"basis": source.get("formula_text") or "",
|
||||
"formula": source.get("formula") or "",
|
||||
# 되돌릴 자리 — 고친 줄만 원래 양식 식이 따로 옴.
|
||||
"default_formula": source.get("default_formula") or source.get("formula") or "",
|
||||
"unit_amount": unit_amount,
|
||||
"amount": None if unit_amount is None else unit_amount * billing,
|
||||
"unit": source.get("unit") or "",
|
||||
"basis_kind": "derived",
|
||||
"source": source.get("source") or "library",
|
||||
"destination": source.get("destination") or "",
|
||||
"rounding": source.get("rounding"),
|
||||
"default_rounding": source.get("default_rounding", source.get("rounding")),
|
||||
"skipped": bool(result.get("skipped")),
|
||||
"reason": result.get("reason") or "",
|
||||
"error": result.get("error") or "",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _user_basis(row: dict[str, Any]) -> str:
|
||||
"""사용자가 고친 줄의 근거 — 고친 칸만 적음(식 · 반올림), 양식 값을 괄호로."""
|
||||
parts = []
|
||||
if row.get("formula") != row.get("default_formula"):
|
||||
parts.append(f"사용자 식 = {row.get('formula')} (양식 식 {row.get('default_formula')})")
|
||||
if rounding_key(row.get("rounding")) != rounding_key(row.get("default_rounding")):
|
||||
parts.append(
|
||||
f"사용자 반올림 = {_rounding_words(row.get('rounding'))}"
|
||||
f" (양식 {_rounding_words(row.get('default_rounding'))})"
|
||||
)
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def _rounding_words(rounding: dict[str, Any] | None) -> str:
|
||||
mode, digits = rounding_key(rounding)
|
||||
word = ROUNDING_WORDS.get(mode, mode)
|
||||
return word if mode == "none" else f"{word} {digits}자리"
|
||||
|
||||
|
||||
def replace_with_templates(
|
||||
quantities: list[Any],
|
||||
inputs: list[dict[str, Any]],
|
||||
section_modes: dict[float, str] | None,
|
||||
rubble_base_thickness_m: float | None,
|
||||
formula_overrides: dict[str, Any] | None = None,
|
||||
templates: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""`build_table` 이 부름 — 양식이 있는 종류의 성분을 **양식 풀이 값으로 갈음**(자리에서).
|
||||
|
||||
⚠ 전개가 성분을 하나도 못 낸 구조물(표에 없는 뒷길이 등)은 그대로 둠 — 전개 사유가 이미 드러냄.
|
||||
⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게.
|
||||
⚠ 오류 난 양식 줄은 성분에서 빼고 사유로 — 전개도 원문 「-」 줄을 안 세우고 사유로 둠.
|
||||
⚠ 사용자가 고친 식은 **양식(type_id)** 으로 찾음 — 구조물도와 같은 값이 되게.
|
||||
⚠ **L=1(m당)로 풀고 연장을 곱함** — 실무 구조물도는 m당 값을 줄마다 반올림하고 뒤 줄이 그
|
||||
반올림 값을 참조함. 실제 연장으로 풀면 합계를 반올림하게 되어 구조물도 m당 × 연장과 갈림.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _section_mode_at
|
||||
from common_util.common_util_structure_face_role import structure_face_role
|
||||
|
||||
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
|
||||
targets: list[tuple[Any, dict[str, Any]]] = []
|
||||
for quantity, item in zip(quantities, inputs):
|
||||
template = template_of(quantity.type_id, templates)
|
||||
if template is None or not quantity.components:
|
||||
continue
|
||||
# 구조물도와 같은 조건 — m당 양식만(`apply_templates` 의 `_PER_LENGTH_UNITS`).
|
||||
if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS:
|
||||
continue
|
||||
options = item.get("options") or {}
|
||||
face, face_reason = structure_face_role(
|
||||
_section_mode_at(item, section_modes), options.get("side")
|
||||
)
|
||||
sheet = {
|
||||
"type_id": quantity.type_id,
|
||||
"height_m": quantity.height_m,
|
||||
"options": options,
|
||||
"face": face,
|
||||
"face_reason": face_reason,
|
||||
}
|
||||
structure = {"height_m": quantity.height_m, "length_m": 1.0, "options": options}
|
||||
values = template_vars(template, structure, slope_of(sheet)[0], settings)
|
||||
overrides = (formula_overrides or {}).get(quantity.type_id)
|
||||
targets.append((quantity, template_sheet(template, values, overrides)))
|
||||
if not targets:
|
||||
return
|
||||
|
||||
solved = evaluate_sheets([body for _quantity, body in targets])
|
||||
for index, (quantity, body) in enumerate(targets):
|
||||
if solved is None:
|
||||
quantity.notes.append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
|
||||
continue
|
||||
template = template_of(quantity.type_id, templates) or {}
|
||||
by_seq = {row["seq"]: row for row in body["rows"]}
|
||||
# 값의 **자료 출처**(야면석 무게 = 울진 관측 등)는 전개가 붙인 그대로.
|
||||
# 양식이냐는 구조물 단위(`library_item`)로 따로 둠.
|
||||
engine_source = {c.name: c.source for c in quantity.components}
|
||||
# 근거 문구도 전개 것을 씀 — 「강도 210 — 안 정해 기본값」·「판정 1:0.3 성토」처럼
|
||||
# **그 구조물에서 왜 그 값인지**가 들어 있어 양식의 붙박이 설명보다 많이 말함(값은 같음).
|
||||
# ⚠ 사용자가 식을 고친 줄은 전개 문구가 틀리므로 「사용자 식」 문구로 감.
|
||||
engine_basis = {c.name: c.basis for c in quantity.components}
|
||||
components = []
|
||||
for result in solved[index]:
|
||||
if result.get("skipped"):
|
||||
continue
|
||||
source = by_seq.get(result["seq"]) or {}
|
||||
if result.get("error"):
|
||||
quantity.notes.append(f"양식 줄 「{result['name']}」이 안 섬 — {result['error']}")
|
||||
continue
|
||||
# 돌 줄도 이름 「돌」 + 규격에 종류 그대로(명세 13장 Ⓒ) — 자재총괄이 규격까지 보고 묶음.
|
||||
name = str(result["name"])
|
||||
user = source.get("source") == "user"
|
||||
basis = (
|
||||
_user_basis(source)
|
||||
if user
|
||||
else engine_basis.get(name) or str(source.get("formula_text") or "")
|
||||
)
|
||||
components.append(
|
||||
Component(
|
||||
name,
|
||||
str(source.get("unit") or ""),
|
||||
float(result["amount"]) * quantity.length_m,
|
||||
str(source.get("destination") or ""),
|
||||
basis,
|
||||
source="user" if user else engine_source.get(name, ""),
|
||||
spec=str(result.get("spec") or ""),
|
||||
)
|
||||
)
|
||||
quantity.components = components
|
||||
# 「양식 있음/없음」을 화면이 가리게 — 조용히 섞이면 왜 값이 다른지 못 찾음.
|
||||
quantity.library_item = str(template.get("name") or quantity.type_id)
|
||||
|
||||
|
||||
def apply_templates(
|
||||
payload: dict[str, Any],
|
||||
settings: dict[str, Any] | None = None,
|
||||
templates: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""구조물도 장마다 **양식이 있으면 양식으로 줄을 다시 세움**(자리에서 고침).
|
||||
|
||||
⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `formula` 빈칸 = 고정형 모양(명세 13장).
|
||||
⚠ Node 풀이가 안 돌면 전개 줄을 두고 **그 사실을 장 사유에 적음** — 조용히 넘기지 않음.
|
||||
⚠ 장은 제원 조합 하나라 **L=1(m당)** 으로 풂 — 연장은 제원이 아니고 모든 줄이 L 에 비례.
|
||||
⚠ 화면이 조작 중 **왕복 없이** 다시 풀 수 있게 풀이 장 한 벌(`formula_sheet`)을 함께 실음.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
|
||||
|
||||
all_overrides = (settings or {}).get(OVERRIDES_KEY) or {}
|
||||
targets: list[tuple[dict[str, Any], dict[str, Any]]] = []
|
||||
for sheet in payload.get("sheets") or []:
|
||||
template = template_of(str(sheet.get("type_id") or ""), templates)
|
||||
if template is None or sheet.get("billing_unit") not in _PER_LENGTH_UNITS:
|
||||
for row in sheet.get("rows") or []:
|
||||
row.setdefault("formula", "")
|
||||
row["source"] = row.get("source") or "auto"
|
||||
continue
|
||||
structure = {
|
||||
"height_m": sheet.get("height_m"),
|
||||
"length_m": 1.0,
|
||||
"options": sheet.get("options"),
|
||||
}
|
||||
values = template_vars(template, structure, slope_of(sheet)[0], settings)
|
||||
body = template_sheet(template, values, all_overrides.get(sheet.get("type_id")))
|
||||
targets.append((sheet, body))
|
||||
|
||||
if not targets:
|
||||
return
|
||||
solved = evaluate_sheets([body for _sheet, body in targets])
|
||||
for index, (sheet, body) in enumerate(targets):
|
||||
template = template_of(str(sheet.get("type_id") or ""), templates) or {}
|
||||
if solved is None:
|
||||
sheet["notes"].append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
|
||||
continue
|
||||
members = sheet.get("members") or []
|
||||
billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0
|
||||
sheet["rows"] = _library_rows(body, solved[index], billing)
|
||||
sheet["unpriced_rows"] = [
|
||||
row["name"]
|
||||
for row in sheet["rows"]
|
||||
if row["unit_amount"] is None and not row["skipped"]
|
||||
]
|
||||
# 「어느 단에서 가져왔나」 — 안 가져왔으면 `imported_from` 없음(= 기본 · 가져오기 전).
|
||||
sheet["library_item"] = {
|
||||
"type_id": template.get("type_id"),
|
||||
"name": template.get("name"),
|
||||
"code": template.get("code"),
|
||||
"imported_from": template.get("imported_from"),
|
||||
}
|
||||
sheet["formula_sheet"] = body
|
||||
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
|
||||
sheet["var_candidates"] = [
|
||||
{
|
||||
"name": name,
|
||||
"label": spec.get("label") or name,
|
||||
"value": body["vars"].get(name),
|
||||
"candidates": spec["candidates"],
|
||||
}
|
||||
for name, spec in (template.get("vars") or {}).items()
|
||||
if spec.get("candidates")
|
||||
]
|
||||
|
||||
|
||||
def save_sheet_overrides(
|
||||
current: dict[str, Any] | None,
|
||||
key: str,
|
||||
template: dict[str, Any],
|
||||
edits: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""양식 하나(`key` = type_id)의 고친 식·반올림을 갈아 끼운 **새 전체 값**과 바뀐 줄 수.
|
||||
|
||||
한 줄 고침은 **그 줄의 뜻한 상태 전부**(식·반올림) — 빠진 칸은 양식대로.
|
||||
⚠ 빈 칸·양식과 같은 값은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면
|
||||
양식이 바뀌어도 그 장만 옛 값으로 남음.
|
||||
⚠ 양식에 없는 차례는 받지 않음(오류) — 모르는 줄이 산출물에 끼지 않게.
|
||||
"""
|
||||
known = {row["seq"]: row for row in template.get("rows") or []}
|
||||
merged = {name: dict(rows) for name, rows in (current or {}).items()}
|
||||
sheet = merged.get(key, {})
|
||||
changed = 0
|
||||
for edit in edits:
|
||||
seq = int(edit["seq"])
|
||||
if seq not in known:
|
||||
raise ValueError(f"양식에 없는 줄 차례: {seq}")
|
||||
entry = _edit_of(known[seq], edit)
|
||||
before = sheet.get(str(seq))
|
||||
if not entry:
|
||||
if sheet.pop(str(seq), None) is not None:
|
||||
changed += 1
|
||||
continue
|
||||
if before != entry:
|
||||
changed += 1
|
||||
sheet[str(seq)] = entry
|
||||
if sheet:
|
||||
merged[key] = sheet
|
||||
else:
|
||||
merged.pop(key, None)
|
||||
return merged, changed
|
||||
@@ -0,0 +1,261 @@
|
||||
"""구조물도 하단 **일위대가 표** — 양식 줄 조합 × B09 단가표로 단위당 금액 미리보기(PLAN 3장).
|
||||
|
||||
⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 줄 값.
|
||||
⚠ 못 푼 줄은 0 이 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 「미완」.
|
||||
⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 계금 1원 미만 버림(품셈 1-2-2).
|
||||
하위 일위대가를 윗 표에 넣을 때는 **안 자른 값**을 씀 — B09 `resolve` 도 조립 중엔 안 자름.
|
||||
⚠ **재귀** — 줄이 `B-AX-ST-*`(다른 구조물 양식)를 가리키면 그 양식을 제원(`sub_vars`)으로 풀어
|
||||
일위대가를 먼저 세우고 그 단위당 금액을 씀. 레시피 150/350 이 2단 이상(명세 16장).
|
||||
깊이 **5단**까지(PLAN 10장 판정) · 돌면 막힘. B-FP·X·L 쪽 재귀는 단가표가 이미 함.
|
||||
⛔ 하위 양식은 **프로젝트에 박힌 것과 프로그램 기본**에서만 찾음 — 개인·회사 단은 안 읽음(4장 Ⓑ).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
#: 하위 구조물 일위대가 코드 머리 — 명세 2장 ② `B-AX-ST-3f9a2b17`.
|
||||
SUB_STRUCTURE_PREFIX = "B-AX-ST-"
|
||||
#: 재귀 깊이 한도 — 맨 윗 표가 1단(PLAN 10장 「재귀 깊이 = 5단」).
|
||||
MAX_DEPTH = 5
|
||||
_UNIT_ALIASES = {"m2": "㎡", "m3": "㎥", "M2": "㎡", "M3": "㎥", "M": "m"}
|
||||
|
||||
|
||||
def _unit(text: Any) -> str:
|
||||
value = str(text or "").strip()
|
||||
return _UNIT_ALIASES.get(value, value)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Context:
|
||||
book: Any
|
||||
find_variant: Callable[[str, str], str | None]
|
||||
library: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Priced:
|
||||
"""줄 단가 한 벌 — 안 자른 3분할과 이름·단위."""
|
||||
|
||||
money: Any
|
||||
name: str
|
||||
spec: str
|
||||
unit: str
|
||||
|
||||
|
||||
def _ref_of(
|
||||
row: dict[str, Any], values: dict[str, Any], find_variant: Callable[[str, str], str | None]
|
||||
) -> tuple[str | None, str]:
|
||||
"""줄이 가리키는 단가표 코드 — 못 정하면 `None` 과 까닭."""
|
||||
if row.get("ref_code"):
|
||||
return str(row["ref_code"]), ""
|
||||
code = row.get("work_item_code")
|
||||
if not code:
|
||||
return None, "공종 코드 미정"
|
||||
var = row.get("variant_from")
|
||||
if not var:
|
||||
return f"B-{code}", ""
|
||||
value = values.get(var)
|
||||
found = find_variant(str(code), str(value if value is not None else ""))
|
||||
if found:
|
||||
return found, ""
|
||||
return None, f"{code} 에서 {var}={value} 에 맞는 갈래를 못 찾음"
|
||||
|
||||
|
||||
def _sub_sheet(sub: dict[str, Any], sub_vars: dict[str, Any]) -> dict[str, Any]:
|
||||
"""하위 양식을 **줄이 준 제원**으로 단위당(L=1) 풂 — 윗 장과 같은 모양의 장 한 벌."""
|
||||
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import template_sheet
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
|
||||
|
||||
values: dict[str, Any] = {}
|
||||
missing = []
|
||||
for name, spec in (sub.get("vars") or {}).items():
|
||||
if name in sub_vars:
|
||||
values[name] = sub_vars[name]
|
||||
elif "default" in spec:
|
||||
values[name] = spec["default"]
|
||||
elif spec.get("source") == "length_m":
|
||||
values[name] = 1.0
|
||||
else:
|
||||
missing.append(name)
|
||||
if missing:
|
||||
raise PriceBookError(
|
||||
f"하위 양식 {sub.get('code')} 의 제원이 비어 있음: {', '.join(missing)}"
|
||||
)
|
||||
body = template_sheet(sub, values)
|
||||
solved = evaluate_sheets([body])
|
||||
if solved is None:
|
||||
raise PriceBookError("식 풀이기를 못 돌려 하위 양식을 못 풂")
|
||||
units = {row["seq"]: row.get("unit") for row in body["rows"]}
|
||||
rows = [
|
||||
{
|
||||
"no": result["seq"],
|
||||
"unit": units.get(result["seq"]),
|
||||
"unit_amount": None if result.get("amount") is None else float(result["amount"]),
|
||||
"skipped": bool(result.get("skipped")),
|
||||
"reason": result.get("reason") or "",
|
||||
"error": result.get("error") or "",
|
||||
}
|
||||
for result in solved[0]
|
||||
]
|
||||
unit = (sub.get("unit_price") or {}).get("unit") or "m"
|
||||
return {"rows": rows, "formula_sheet": {"vars": values}, "billing_unit": unit}
|
||||
|
||||
|
||||
def _price(
|
||||
ref: str, row: dict[str, Any], ctx: _Context, depth: int, seen: tuple[str, ...]
|
||||
) -> _Priced:
|
||||
"""줄 하나의 단가 — B-AX-ST 는 하위 양식을 재귀로, 나머지는 B09 단가표."""
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceBookError
|
||||
|
||||
if not ref.startswith(SUB_STRUCTURE_PREFIX):
|
||||
title = ctx.book.title(ref)
|
||||
return _Priced(ctx.book.resolve(ref), title.name, title.spec, _unit(title.unit))
|
||||
code = ref[2:]
|
||||
if code in seen:
|
||||
raise PriceBookError(f"하위 일위대가가 돌고 있음: {' → '.join((*seen, code))}")
|
||||
if depth + 1 > MAX_DEPTH:
|
||||
raise PriceBookError(f"하위 일위대가가 {MAX_DEPTH}단을 넘음: {' → '.join((*seen, code))}")
|
||||
sub = ctx.library.get(code)
|
||||
if sub is None:
|
||||
raise PriceBookError(f"하위 양식 {code} 을 프로젝트·프로그램 기본에서 못 찾음")
|
||||
sheet = _sub_sheet(sub, row.get("sub_vars") or {})
|
||||
assembled = _assemble(sub, sheet, ctx, depth + 1, (*seen, code))
|
||||
if assembled is None:
|
||||
raise PriceBookError(f"하위 양식 {code} 에 일위대가 줄이 없음")
|
||||
table, money = assembled
|
||||
if table["blocked"]:
|
||||
raise PriceBookError(f"하위 일위대가 {code} 미완 — 막힌 줄 {table['blocked']}")
|
||||
return _Priced(money, str(sub.get("name") or code), "", _unit(sheet["billing_unit"]))
|
||||
|
||||
|
||||
def _assemble(
|
||||
template: dict[str, Any],
|
||||
sheet: dict[str, Any],
|
||||
ctx: _Context,
|
||||
depth: int,
|
||||
seen: tuple[str, ...],
|
||||
) -> tuple[dict[str, Any], Any] | None:
|
||||
"""표 한 벌과 **안 자른** 단위당 3분할 합(윗 표가 쓸 값)."""
|
||||
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBookError
|
||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||
|
||||
spec = template.get("unit_price")
|
||||
if not spec:
|
||||
return None
|
||||
sheet_rows = {row.get("no"): row for row in sheet.get("rows") or []}
|
||||
values = (sheet.get("formula_sheet") or {}).get("vars") or {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)}
|
||||
exact = Money3()
|
||||
blocked = 0
|
||||
for row in spec.get("rows") or []:
|
||||
out: dict[str, Any] = {
|
||||
"seq": row.get("seq"),
|
||||
"name": row.get("name") or "",
|
||||
"spec": row.get("spec") or "",
|
||||
"ref_code": "",
|
||||
"unit": _unit(row.get("unit")),
|
||||
"quantity": None,
|
||||
"skipped": False,
|
||||
"reason": "",
|
||||
}
|
||||
source = sheet_rows.get(row.get("from_row")) if "from_row" in row else None
|
||||
if source is not None and (source.get("skipped") or source.get("error")):
|
||||
# 원단위 줄이 안 선 장이면 일위대가 줄도 안 섬 — 막힘이 아님(버림 「안 넣음」 등).
|
||||
out.update(skipped=bool(source.get("skipped")), reason=source.get("reason") or "")
|
||||
if source.get("error"):
|
||||
out.update(skipped=False, reason=f"원단위 줄이 안 풀림 — {source['error']}")
|
||||
blocked += 1
|
||||
rows.append(out)
|
||||
continue
|
||||
if "from_row" in row:
|
||||
if source is None or source.get("unit_amount") is None:
|
||||
out["reason"] = f"원단위 줄 {row['from_row']} 이 없음"
|
||||
blocked += 1
|
||||
rows.append(out)
|
||||
continue
|
||||
quantity = Decimal(str(source["unit_amount"]))
|
||||
out["unit"] = out["unit"] or _unit(source.get("unit"))
|
||||
elif row.get("quantity") is not None:
|
||||
quantity = Decimal(str(row["quantity"]))
|
||||
else:
|
||||
out["reason"] = "수량 없음"
|
||||
blocked += 1
|
||||
rows.append(out)
|
||||
continue
|
||||
out["quantity"] = float(quantity)
|
||||
|
||||
ref, why = _ref_of(row, values, ctx.find_variant)
|
||||
out["ref_code"] = ref or ""
|
||||
priced = None
|
||||
if ref:
|
||||
try:
|
||||
priced = _price(ref, row, ctx, depth, seen)
|
||||
if out["unit"] and priced.unit and priced.unit != out["unit"]:
|
||||
why = f"단위가 다름 — 수량 {out['unit']} ↔ 단가 {priced.unit}"
|
||||
priced = None
|
||||
except PriceBookError as exc:
|
||||
why = str(exc)
|
||||
if priced is None:
|
||||
out["reason"] = why
|
||||
blocked += 1
|
||||
rows.append(out)
|
||||
continue
|
||||
|
||||
money = priced.money
|
||||
exact = exact + money.scaled(quantity)
|
||||
cells = {
|
||||
"material": round_at(money.material * quantity, OutputPlace.UNIT_PRICE_ROW),
|
||||
"labor": round_at(money.labor * quantity, OutputPlace.UNIT_PRICE_ROW),
|
||||
"expense": round_at(money.expense * quantity, OutputPlace.UNIT_PRICE_ROW),
|
||||
}
|
||||
for key, value in cells.items():
|
||||
sums[key] += value
|
||||
out.update(
|
||||
name=priced.name or out["name"],
|
||||
spec=priced.spec or out["spec"],
|
||||
depth=depth,
|
||||
unit_material=float(money.material),
|
||||
unit_labor=float(money.labor),
|
||||
unit_expense=float(money.expense),
|
||||
**{key: float(value) for key, value in cells.items()},
|
||||
total=float(sum(cells.values())),
|
||||
)
|
||||
rows.append(out)
|
||||
|
||||
table = {
|
||||
"code": f"B-{template.get('code')}" if template.get("code") else "",
|
||||
"name": template.get("name") or "",
|
||||
"unit": sheet.get("billing_unit") or "",
|
||||
"rows": rows,
|
||||
**{key: float(value) for key, value in sums.items()},
|
||||
# 계금 — 1원 미만 버림. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음.
|
||||
"total": float(round_at(sum(sums.values()), OutputPlace.UNIT_PRICE_TOTAL)),
|
||||
"blocked": blocked,
|
||||
"complete": blocked == 0,
|
||||
}
|
||||
return table, exact
|
||||
|
||||
|
||||
def unit_price_table(
|
||||
template: dict[str, Any],
|
||||
sheet: dict[str, Any],
|
||||
book: Any,
|
||||
find_variant: Callable[[str, str], str | None],
|
||||
library: Iterable[dict[str, Any]] = (),
|
||||
) -> dict[str, Any] | None:
|
||||
"""장 하나의 일위대가 표. 양식에 `unit_price` 가 없으면 `None`.
|
||||
|
||||
수량 — `from_row`(원단위 줄 차례 → 그 줄의 단위당 값) 또는 박힌 `quantity`.
|
||||
코드 — `ref_code`(단가표 코드 그대로) 또는 `work_item_code` + `variant_from`(제원 칸 → 갈래).
|
||||
`library` — 하위 구조물 일위대가(`B-AX-ST-*`)를 찾을 양식들(프로젝트에 박힌 것 + 프로그램 기본).
|
||||
"""
|
||||
ctx = _Context(book, find_variant, {str(t["code"]): t for t in library if t.get("code")})
|
||||
code = str(template.get("code") or "")
|
||||
assembled = _assemble(template, sheet, ctx, 1, (code,) if code else ())
|
||||
return assembled[0] if assembled else None
|
||||
@@ -93,7 +93,7 @@ STONE_WEIGHT_BASIS = (
|
||||
#: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다.
|
||||
STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫
|
||||
STONE_DENSITY_TON_M3 = 2.65 # 석재 비중
|
||||
#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우도 이쪽(지금까지 쓰던 값이 그것).
|
||||
#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우는 **계산식**으로 선다(`stone_masonry` 주석).
|
||||
STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"})
|
||||
STONE_WEIGHT_FORMULA_BASIS = (
|
||||
"⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값"
|
||||
@@ -293,7 +293,9 @@ STONE_MASONRY = {
|
||||
# ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m ·
|
||||
# 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을
|
||||
# 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다.
|
||||
"weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이
|
||||
# ⓘ 2026-09-13 — 돌쌓기(기슭막이)는 **평균두께**로 갈음(브레인 판정).
|
||||
# 이 값은 골막이 정본용으로만 남음.
|
||||
"weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 — 골막이
|
||||
"mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만)
|
||||
# ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다
|
||||
# (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다).
|
||||
@@ -381,7 +383,6 @@ DESTINATION = {
|
||||
"돌쌓기": "unit_price",
|
||||
"돌붙임": "unit_price",
|
||||
"깬돌": "material",
|
||||
"야면석": "material",
|
||||
"고임돌": "material",
|
||||
"막자갈": "material",
|
||||
# ⭐ 2026-09-09 사용자 확정 3차 ⑥ — **콘크리트는 자재 축에 세운다**(사용자 명시).
|
||||
@@ -404,11 +405,8 @@ DESTINATION = {
|
||||
# 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다.
|
||||
"입적": "reference",
|
||||
"석적": "reference",
|
||||
# 돌 종류를 고르면 그 이름으로 줄이 선다 — 자재총괄이 이름으로 찾으므로 넷 다 둔다.
|
||||
"야면석·호박돌": "material",
|
||||
"깬잡석": "material",
|
||||
"견치돌": "material",
|
||||
# 종류를 안 고른 경우의 이름 — 정본 계산표 줄 이름 그대로다(「돌 ℓ3=45cm」).
|
||||
# 돌 줄은 **이름 「돌」 + 규격(`spec`)에 종류**다(명세 13장 Ⓒ) — 정본 계산표 줄 이름 그대로
|
||||
# (「돌 ℓ3=45cm」). 자재총괄이 (이름·규격·단위)로 묶으므로 종류 이름을 따로 두지 않는다.
|
||||
"돌": "material",
|
||||
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
|
||||
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
|
||||
@@ -466,6 +464,8 @@ class StructureQuantity:
|
||||
#: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다.
|
||||
ground_type: str | None = None
|
||||
ground_type_basis: str = ""
|
||||
#: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값.
|
||||
library_item: str = ""
|
||||
|
||||
|
||||
#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다.
|
||||
@@ -598,14 +598,6 @@ def _blinding_component(
|
||||
)
|
||||
|
||||
|
||||
#: 기초잡석(품셈 12-25)이 아직 못 서는 까닭 — **폭은 있고 두께가 없다.**
|
||||
RUBBLE_BASE_BLOCKED = (
|
||||
"기초잡석(12-25) 물량이 안 섬 — 폭은 하단 길이 {width:.2f}m 로 섰으나 **두께가 원문에 "
|
||||
"없음**(품셈 12-25 는 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라 "
|
||||
"다른 구조물 값을 옮겨 쓰지 않음"
|
||||
)
|
||||
|
||||
|
||||
#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인).
|
||||
#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다**
|
||||
#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다.
|
||||
@@ -904,6 +896,13 @@ def stone_masonry(
|
||||
# ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로
|
||||
# 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다.
|
||||
kind_label = str(picked.get("kind") or "")
|
||||
# ⭐ 2026-09-13 브레인 판정 — **계수 열 고르기와 돌종류는 다른 축.** 「실무 관행」은 계수 열만
|
||||
# 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 규격·무게는
|
||||
# **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 규격 빈칸).
|
||||
chosen_kind = str(options.get(STONE_KIND_OPTION) or "").strip()
|
||||
stone_kind_name = (
|
||||
chosen_kind if chosen_kind in (load_stone_kind_table().get("kinds") or []) else kind_label
|
||||
)
|
||||
# ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도
|
||||
# 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이
|
||||
# 이긴다. (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것.)
|
||||
@@ -984,10 +983,10 @@ def stone_masonry(
|
||||
# ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고,
|
||||
# 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다).
|
||||
stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2(
|
||||
back_cm, kind_label, table["stone_ton_per_m2"]
|
||||
back_cm, stone_kind_name, table["stone_ton_per_m2"]
|
||||
)
|
||||
weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else ""
|
||||
if not kind_label:
|
||||
if not stone_kind_name:
|
||||
notes.append(
|
||||
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — "
|
||||
"야면석이면 계산식이 안 맞아 관측표로 갈립니다"
|
||||
@@ -998,12 +997,13 @@ def stone_masonry(
|
||||
else:
|
||||
components.append(
|
||||
Component(
|
||||
stone_name,
|
||||
"돌",
|
||||
"ton",
|
||||
masonry_area * stone_ton,
|
||||
DESTINATION.get(stone_name, "material"),
|
||||
DESTINATION["돌"],
|
||||
weight_basis,
|
||||
source=weight_source,
|
||||
spec=stone_kind_name,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1066,7 +1066,7 @@ def stone_masonry(
|
||||
)
|
||||
# ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢).
|
||||
|
||||
# 물구멍 — 벽면적 2㎡당 1개소, 개소당 0.5m.
|
||||
# 물구멍 — 벽면적 2㎡당 1개소, 개소당 관 길이 = **평균두께**(관이 벽을 가로지름).
|
||||
# ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고
|
||||
# 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다.
|
||||
# ⚠⚠ **㉥ 이중계상** — 품셈 13-6-2·13-7-2 [주]③ 은 제잡비 **윗단** 값에
|
||||
@@ -1078,17 +1078,17 @@ def stone_masonry(
|
||||
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
|
||||
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
|
||||
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
|
||||
# ⓘ 정본은 개소당 관 길이를 **평균두께**로 잡는다(H=2.0 에서 0.83m). 우리는 상수 0.5m 라
|
||||
# 그만큼 짧게 선다 — 관은 벽을 가로지르므로 정본 쪽이 이치에 맞는다(미결).
|
||||
# ⭐ 2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = **평균두께**. 정본이 그렇게 잡고
|
||||
# 관은 벽을 가로지르므로 이치에도 맞음. 옛 상수 0.5m 는 그만큼 짧게 섰음.
|
||||
# ⚠ 골막이(`_Revetment`)는 그 정본이 0.5 라 그대로 둠 — 여기는 기슭막이 계열만.
|
||||
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
|
||||
components.append(
|
||||
Component(
|
||||
"물구멍관",
|
||||
"m",
|
||||
masonry_area / hole_area * constants["weep_hole_length_m"],
|
||||
masonry_area / hole_area * thickness,
|
||||
DESTINATION["물구멍관"],
|
||||
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {constants['weep_hole_length_m']:g} m/개소"
|
||||
f" · {hole_basis}",
|
||||
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × 평균두께 {thickness:.2f} m/개소 · {hole_basis}",
|
||||
spec=f"Ø{hole_dia}",
|
||||
)
|
||||
)
|
||||
@@ -1122,8 +1122,8 @@ def stone_masonry(
|
||||
)
|
||||
if blinding is not None:
|
||||
components.append(blinding)
|
||||
|
||||
notes.append(RUBBLE_BASE_BLOCKED.format(width=base_width))
|
||||
# ⓘ 기초잡석은 `build_table` 이 버림 뒤에 세움(확정 3차 ② 두께 0.2 · PLAN 10장 「계상함」).
|
||||
# 옛 「두께가 없어 안 섬」 사유는 걷음 — 줄과 사유가 함께 떠 서로 어긋났음(2026-09-13 화면 실측).
|
||||
|
||||
# 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다).
|
||||
components.extend(
|
||||
@@ -1506,8 +1506,20 @@ def build_table(
|
||||
section_modes: dict[float, str] | None = None,
|
||||
ground_types: dict[float, str] | None = None,
|
||||
rubble_base_thickness_m: float | None = None,
|
||||
use_templates: bool = True,
|
||||
structure_formulas: dict[str, Any] | None = None,
|
||||
structure_templates: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다."""
|
||||
"""화면·API 가 그대로 쓰는 모양. 성분별 총량과 구조물별 내역을 함께 낸다.
|
||||
|
||||
⭐ 2026-09-13(PLAN 3장 ④-2) — **양식이 있는 종류는 양식 풀이 값으로 성분을 갈음**
|
||||
(`B08_Quantity_Engine_StructureTemplate.replace_with_templates`). 원단위·자재총괄·인계가
|
||||
구조물도와 같은 값을 보게 함. `use_templates=False` 는 대조 시험이 **전개만** 볼 때 씀.
|
||||
⭐ `structure_formulas` — 사용자가 양식마다 고친 식(산출 조건 `structure_formula_overrides`,
|
||||
PLAN 3장 ⑤). 부르는 쪽이 산출 조건에서 넘김 — 안 넘기면 구조물도와 값이 갈림.
|
||||
⭐ `structure_templates` — 프로젝트에 박힌 양식(PLAN 4장 가져오기, `project_templates`).
|
||||
없는 종류는 프로그램 기본. 같은 까닭으로 부르는 쪽이 넘김.
|
||||
"""
|
||||
observed = load_observed_table()
|
||||
# 딸린 줄(배수관의 유입부 집수정 등)을 원본 뒤에 세운다 — 한 줄로 합치지 않는다.
|
||||
expanded_inputs: list[dict[str, Any]] = []
|
||||
@@ -1523,16 +1535,29 @@ def build_table(
|
||||
if rubble is not None:
|
||||
quantity.components.append(rubble)
|
||||
quantities.append(quantity)
|
||||
if use_templates:
|
||||
# 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림.
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import replace_with_templates
|
||||
|
||||
replace_with_templates(
|
||||
quantities,
|
||||
expanded_inputs,
|
||||
section_modes,
|
||||
rubble_base_thickness_m,
|
||||
structure_formulas,
|
||||
structure_templates,
|
||||
)
|
||||
violations = verify_no_mix_components(quantities)
|
||||
|
||||
totals: dict[str, dict[str, Any]] = {}
|
||||
for item in quantities:
|
||||
for component in item.components:
|
||||
key = f"{component.name}|{component.unit}"
|
||||
key = f"{component.name}|{component.spec}|{component.unit}"
|
||||
entry = totals.setdefault(
|
||||
key,
|
||||
{
|
||||
"name": component.name,
|
||||
"spec": component.spec,
|
||||
"unit": component.unit,
|
||||
"amount": 0.0,
|
||||
"destination": component.destination,
|
||||
@@ -1558,6 +1583,8 @@ def build_table(
|
||||
# ⚠ 여기서 새로 만드는 값이 아니라 측점 설계값(`design.ground_type`)을 옮긴 것이다.
|
||||
"ground_type": item.ground_type,
|
||||
"ground_type_basis": item.ground_type_basis,
|
||||
# 양식 있음/없음 — 화면이 가림(비면 지금 전개).
|
||||
"library_item": item.library_item,
|
||||
"notes": item.notes,
|
||||
"components": [
|
||||
{
|
||||
|
||||
@@ -37,7 +37,7 @@ WITHHELD_FORMS: dict[str, str] = {
|
||||
"콘크리트 기슭막이는 돌쌓기(품셈 13-4)가 아니라 콘크리트 구조물이라 전개식이 다름 "
|
||||
"— 벽 두께·저판이 정본에 없어 물량이 서지 않음"
|
||||
),
|
||||
"돌망태": ("돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보"),
|
||||
"돌망태": ("돌망태는 품셈 13-11 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보"),
|
||||
"통나무·목재틀": (
|
||||
"목재틀은 품셈 13-13 이고 밑수가 ㎥당 목공 품이라 돌쌓기 표를 못 씀 "
|
||||
"— 각재·판재 자재가 카탈로그에 없어 값이 모자람"
|
||||
@@ -73,7 +73,8 @@ BED_SILL_FORMS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"돌붙임(메)": {
|
||||
"back_len_m": 0.30,
|
||||
"stone_name": "야면석",
|
||||
"stone_name": "돌",
|
||||
"stone_kind": "야면석", # 규격 칸으로(명세 13장 Ⓒ)
|
||||
"stone_spec": "20×20×30㎝",
|
||||
"stone_ton_per_m2": 0.42,
|
||||
"wedge_stone_m3_per_m2": 0.07,
|
||||
@@ -161,7 +162,7 @@ EROSION_CHECK_FORMS: frozenset[str] = frozenset({"돌"})
|
||||
|
||||
#: 나머지 형식과 **왜 없는지**. 지어내지 않는다.
|
||||
EROSION_CHECK_WITHHELD: dict[str, str] = {
|
||||
"돌망태": "돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보",
|
||||
"돌망태": "돌망태는 품셈 13-11 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보",
|
||||
"콘크리트": "콘크리트 골막이는 몸체 두께·저판이 정본에 없어 물량이 서지 않음",
|
||||
"통나무": (
|
||||
"통나무 골막이는 품셈 13-13(목재틀)이고 밑수가 ㎥당 목공 품이라 축이 다름 — 원단위 미확보"
|
||||
@@ -283,7 +284,7 @@ def erosion_check_dam(
|
||||
facing = top_m * (top_t - back_m)
|
||||
base_area = masonry + facing
|
||||
|
||||
rows: list[tuple[str, str, float, str]] = [
|
||||
rows: list[tuple] = [
|
||||
("돌쌓기", "㎡", masonry, f"정면적 {front_area:.4f}㎡ × √(1+n²) · 기울기 {slope_note}"),
|
||||
("돌붙임", "㎡", facing, f"상장 {top_m:g}m × (상부두께 {top_t:g} − 뒷길이 {back_m:g})"),
|
||||
(
|
||||
@@ -305,10 +306,11 @@ def erosion_check_dam(
|
||||
else:
|
||||
rows.append(
|
||||
(
|
||||
stone_name,
|
||||
"돌",
|
||||
"ton",
|
||||
_ceil2(base_area * stone_ton),
|
||||
f"(돌쌓기+돌붙임) {base_area:.4f}㎡ {weight_tail}",
|
||||
kind, # 종류는 규격 칸으로(명세 13장 Ⓒ)
|
||||
)
|
||||
)
|
||||
wedge_per = coeff["wedge_stone_m3_per_m2"]
|
||||
@@ -358,8 +360,8 @@ def erosion_check_dam(
|
||||
)
|
||||
|
||||
components = [
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
|
||||
for name, unit, amount, basis in rows
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
|
||||
for name, unit, amount, basis, *spec in rows
|
||||
]
|
||||
mpa, mpa_basis = fill_concrete_mpa(options)
|
||||
fill_per = coeff["fill_concrete_m3_per_m2"]
|
||||
@@ -548,7 +550,7 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
|
||||
return [], ["면적이 없어 전개하지 않음"]
|
||||
|
||||
back = table["back_len_m"]
|
||||
rows: list[tuple[str, str, float, str]] = [
|
||||
rows: list[tuple] = [
|
||||
("돌붙임", "㎡", area_m2, f"면적 {area_m2:g}㎡ (평면적 — 기울기 몫 없음)"),
|
||||
("입적", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m"),
|
||||
(
|
||||
@@ -561,6 +563,7 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
|
||||
if form == "돌붙임(찰)"
|
||||
else " (원문 직접값 — 야면석은 유도식이 안 맞음)"
|
||||
),
|
||||
table.get("stone_kind", ""),
|
||||
),
|
||||
(
|
||||
"고임돌",
|
||||
@@ -581,8 +584,8 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
|
||||
rows.append(("터파기", "㎥", area_m2 * back, f"면적 × 두께 {back:g}m"))
|
||||
|
||||
components = [
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
|
||||
for name, unit, amount, basis in rows
|
||||
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
|
||||
for name, unit, amount, basis, *spec in rows
|
||||
]
|
||||
notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"]
|
||||
if form == "돌붙임(찰)":
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Formula.ts
|
||||
* 구조물도 **식 칸 풀이기** — 명세 13장(`docs/raw/verification/2026-09-13_축C_명세.md`) 한 벌.
|
||||
*
|
||||
* 화면(조작 중 즉시)과 서버(`B08_Quantity_Formula_Node.ts` — [저장]·[확정] 재계산)가
|
||||
* **이 파일 하나**를 같이 씀(판정 Ⓐ, 2026-09-13). 두 벌로 짜면 반올림이 1원에서 조용히 갈림.
|
||||
*
|
||||
* ⚠ **수는 BigInt 분수**로 듦 — 부동소수는 1.15×100 = 114.999… 라 버림이 114 로 틀어짐.
|
||||
* 실무 엑셀이 `INT(x*100)/100` 으로 뜻한 값은 십진 값의 버림이라 분수로 풀어야 맞음.
|
||||
* ⚠ **적힌 차례대로 풂** — 식을 미리 접거나 바꿔 쓰지 않음(명세 13장 지킬 것 ①).
|
||||
* `267360*5/24` 는 55700, `267360*0.20833` 은 적힌 상수대로 55699.1088 이 남.
|
||||
* ⚠ 무리수만 끊어 분수로 되돌림 — SQRT 는 소수 30자리 버림, 소수 거듭제곱은 유효 17자리.
|
||||
* ⚠ `refs` 는 **앞 줄만** — 뒷줄·자기 줄을 가리키면 오류(순환이 원리적으로 불가).
|
||||
* ⚠ `eval` 을 쓰지 않음 — 식은 사용자·라이브러리에서 오는 글이라 직접 짠 파서로만 읽음.
|
||||
* ========================================================================== */
|
||||
|
||||
/**
|
||||
* 반올림 갈래 — 엑셀 대응(명세 13장 대응표): `INT`=floor · `ROUNDDOWN`=trunc · `ROUNDUP`=ceil_away ·
|
||||
* `ROUND`=round(사사오입). ⚠ 양수에서는 floor=trunc · ceil=ceil_away 라 **음수에서만 갈림**.
|
||||
*/
|
||||
export type RoundingMode =
|
||||
"floor" | "trunc" | "round" | "ceil_away" | "ceil" | "none" | "round_half_even";
|
||||
|
||||
export interface FormulaRounding {
|
||||
mode: RoundingMode;
|
||||
digits: number;
|
||||
}
|
||||
|
||||
/** 갈 곳 — 이중계상 경계를 줄 단위로(명세 13장 Ⓑ). ⛔ 빠지면 오류, 기본값 없음. */
|
||||
export const DESTINATIONS = [
|
||||
"earthwork",
|
||||
"material",
|
||||
"unit_price",
|
||||
"reference",
|
||||
"haul_deduction",
|
||||
] as const;
|
||||
|
||||
/** 식 칸 한 줄 — 명세 13장 「한 줄이 들고 갈 칸」. */
|
||||
export interface FormulaRow {
|
||||
seq: number;
|
||||
name: string;
|
||||
/** 규격. `{제원이름}` 은 그 제원 값으로 바뀜 — 이름은 고정하고 종류는 여기로(명세 13장 Ⓒ). */
|
||||
spec?: string;
|
||||
/** 기계가 푸는 식(정본). 비면 고정형 — `amount` 를 박힌 값으로 씀. */
|
||||
formula?: string | null;
|
||||
formula_text?: string;
|
||||
/** 줄이 서느냐 — 거짓이면 그 줄은 **안 섬**(0 이 아님, 명세 13장 Ⓐ). */
|
||||
when?: string | null;
|
||||
destination?: string;
|
||||
refs?: Record<string, number>;
|
||||
vars?: Record<string, number | string>;
|
||||
amount?: string | number | null;
|
||||
unit?: string;
|
||||
rounding?: FormulaRounding | null;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/** LOOKUP 표 — `keys` 는 **오름차순**이어야 함(근사 일치의 전제). */
|
||||
export interface LookupTable {
|
||||
keys: (number | string)[];
|
||||
columns: Record<string, (number | string | null)[]>;
|
||||
}
|
||||
|
||||
export interface FormulaSheet {
|
||||
rows: FormulaRow[];
|
||||
/** 장 전체에 걸리는 제원 — 줄의 `vars` 가 같은 이름이면 줄이 이김. */
|
||||
vars?: Record<string, number | string>;
|
||||
tables?: Record<string, LookupTable>;
|
||||
}
|
||||
|
||||
export interface FormulaRowResult {
|
||||
seq: number;
|
||||
name: string;
|
||||
/** `{제원}` 을 채운 규격. */
|
||||
spec: string;
|
||||
/** 반올림 뒤 값(십진 문자열). 오류·안 섬이면 `null`. */
|
||||
amount: string | null;
|
||||
/** 반올림 전 값 — 어느 자리에서 갈렸는지 되짚는 용. */
|
||||
raw: string | null;
|
||||
/** `when` 이 거짓이라 안 선 줄 — 화면은 「안 섬」과 `reason` 을 보임. */
|
||||
skipped: boolean;
|
||||
reason: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 분수 */
|
||||
|
||||
const DIGITS = 30;
|
||||
const TEN = 10n;
|
||||
|
||||
interface Frac {
|
||||
n: bigint;
|
||||
d: bigint;
|
||||
}
|
||||
|
||||
function abs(x: bigint): bigint {
|
||||
return x < 0n ? -x : x;
|
||||
}
|
||||
|
||||
function gcd(a: bigint, b: bigint): bigint {
|
||||
a = abs(a);
|
||||
b = abs(b);
|
||||
while (b) [a, b] = [b, a % b];
|
||||
return a || 1n;
|
||||
}
|
||||
|
||||
function frac(n: bigint, d = 1n): Frac {
|
||||
if (d === 0n) throw new FormulaError("0 으로 나눔");
|
||||
if (d < 0n) [n, d] = [-n, -d];
|
||||
const g = gcd(n, d);
|
||||
return { n: n / g, d: d / g };
|
||||
}
|
||||
|
||||
const add = (a: Frac, b: Frac): Frac => frac(a.n * b.d + b.n * a.d, a.d * b.d);
|
||||
const sub = (a: Frac, b: Frac): Frac => frac(a.n * b.d - b.n * a.d, a.d * b.d);
|
||||
const mul = (a: Frac, b: Frac): Frac => frac(a.n * b.n, a.d * b.d);
|
||||
const div = (a: Frac, b: Frac): Frac => {
|
||||
if (b.n === 0n) throw new FormulaError("0 으로 나눔");
|
||||
return frac(a.n * b.d, a.d * b.n);
|
||||
};
|
||||
const cmp = (a: Frac, b: Frac): number => {
|
||||
const left = a.n * b.d;
|
||||
const right = b.n * a.d;
|
||||
return left === right ? 0 : left < right ? -1 : 1;
|
||||
};
|
||||
|
||||
/** 십진 문자열(`-12.5`, `3`, `1e-3`)을 분수로. */
|
||||
function parseDecimal(text: string): Frac {
|
||||
const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text.trim());
|
||||
if (!match || (match[2] === "" && (match[3] ?? "") === "")) {
|
||||
throw new FormulaError(`수로 읽지 못함: ${text}`);
|
||||
}
|
||||
const [, sign, whole, fraction = "", exponent = "0"] = match;
|
||||
let n = BigInt((whole || "0") + fraction);
|
||||
let d = TEN ** BigInt(fraction.length);
|
||||
const e = Number(exponent);
|
||||
if (e > 0) n *= TEN ** BigInt(e);
|
||||
if (e < 0) d *= TEN ** BigInt(-e);
|
||||
return frac(sign === "-" ? -n : n, d);
|
||||
}
|
||||
|
||||
function toFrac(value: number | string): Frac {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new FormulaError(`수가 아님: ${value}`);
|
||||
// ⚠ 수로 온 값은 **보이는 십진 표기**로 읽음 — 0.15 를 이진 근사값으로 받지 않음.
|
||||
return parseDecimal(String(value));
|
||||
}
|
||||
return parseDecimal(value);
|
||||
}
|
||||
|
||||
/** 정수로 떨굼 — `mode` 는 반올림 칸과 같은 낱말. */
|
||||
function toInteger(x: Frac, mode: RoundingMode): bigint {
|
||||
const q = x.n / x.d; // 0 쪽으로 자름
|
||||
const r = x.n % x.d;
|
||||
if (r === 0n) return q;
|
||||
const negative = x.n < 0n;
|
||||
if (mode === "floor") return negative ? q - 1n : q;
|
||||
if (mode === "ceil") return negative ? q : q + 1n;
|
||||
if (mode === "trunc") return q; // 엑셀 ROUNDDOWN — 0 쪽
|
||||
if (mode === "ceil_away") return negative ? q - 1n : q + 1n; // 엑셀 ROUNDUP — 0 에서 먼 쪽
|
||||
const twice = abs(r) * 2n;
|
||||
if (twice === x.d && mode === "round_half_even")
|
||||
return q % 2n === 0n ? q : negative ? q - 1n : q + 1n;
|
||||
// 사사오입 — 엑셀 `ROUND` 와 같이 0 에서 먼 쪽(명세 13장 ⭐).
|
||||
if (twice >= x.d) return negative ? q - 1n : q + 1n;
|
||||
return q;
|
||||
}
|
||||
|
||||
export function applyRounding(x: Frac, rounding?: FormulaRounding | null): Frac {
|
||||
if (!rounding || rounding.mode === "none") return x;
|
||||
const digits = Math.trunc(rounding.digits ?? 0);
|
||||
const scale = digits >= 0 ? frac(TEN ** BigInt(digits)) : frac(1n, TEN ** BigInt(-digits));
|
||||
return div(frac(toInteger(mul(x, scale), rounding.mode)), scale);
|
||||
}
|
||||
|
||||
/** 분수를 십진 문자열로 — 끝나는 소수는 그대로, 안 끝나면 30자리에서 사사오입. */
|
||||
export function fracToString(x: Frac): string {
|
||||
const scaled = toInteger(mul(x, frac(TEN ** BigInt(DIGITS))), "round");
|
||||
const negative = scaled < 0n;
|
||||
const digits = abs(scaled)
|
||||
.toString()
|
||||
.padStart(DIGITS + 1, "0");
|
||||
const whole = digits.slice(0, -DIGITS);
|
||||
const fraction = digits.slice(-DIGITS).replace(/0+$/, "");
|
||||
const body = fraction ? `${whole}.${fraction}` : whole;
|
||||
return negative && body !== "0" ? `-${body}` : body;
|
||||
}
|
||||
|
||||
function sqrt(x: Frac): Frac {
|
||||
if (x.n < 0n) throw new FormulaError("음수의 SQRT");
|
||||
const scale = TEN ** BigInt(DIGITS);
|
||||
// √(n/d) = √(n·d) / d — 소수 30자리까지 정수 제곱근으로.
|
||||
const target = x.n * x.d * scale * scale;
|
||||
if (target === 0n) return frac(0n);
|
||||
// 정수 제곱근(버림) — 참값보다 큰 2 의 거듭제곱에서 뉴턴으로 내려옴(Number 를 안 거침).
|
||||
let root = 1n << BigInt(Math.ceil(target.toString(2).length / 2));
|
||||
for (;;) {
|
||||
const next = (root + target / root) >> 1n;
|
||||
if (next >= root) break;
|
||||
root = next;
|
||||
}
|
||||
return frac(root, x.d * scale);
|
||||
}
|
||||
|
||||
function power(base: Frac, exponent: Frac): Frac {
|
||||
if (exponent.d === 1n) {
|
||||
const e = exponent.n;
|
||||
if (e === 0n) return frac(1n);
|
||||
const magnitude = abs(e);
|
||||
const raised = frac(base.n ** magnitude, base.d ** magnitude);
|
||||
return e > 0n ? raised : div(frac(1n), raised);
|
||||
}
|
||||
// 소수 지수는 무리수 — 유효 17자리 근사(드문 자리: 실무 53탭에 0회).
|
||||
const value = Math.pow(Number(base.n) / Number(base.d), Number(exponent.n) / Number(exponent.d));
|
||||
if (!Number.isFinite(value)) throw new FormulaError("거듭제곱 값이 수가 아님");
|
||||
return parseDecimal(value.toPrecision(17));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 파서 */
|
||||
|
||||
class FormulaError extends Error {}
|
||||
|
||||
type Token =
|
||||
| { kind: "num"; text: string }
|
||||
| { kind: "str"; text: string }
|
||||
| { kind: "id"; text: string }
|
||||
| { kind: "op"; text: string };
|
||||
|
||||
const OPERATORS = ["<=", ">=", "<>", "+", "-", "*", "/", "^", "(", ")", ",", "=", "<", ">"];
|
||||
|
||||
function tokenize(source: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (/\s/.test(ch)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const number = /^(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/.exec(source.slice(i));
|
||||
if (number) {
|
||||
tokens.push({ kind: "num", text: number[0] });
|
||||
i += number[0].length;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
const end = source.indexOf(ch, i + 1);
|
||||
if (end < 0) throw new FormulaError("따옴표가 닫히지 않음");
|
||||
tokens.push({ kind: "str", text: source.slice(i + 1, end) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
const identifier = /^[\p{L}_][\p{L}\p{N}_]*/u.exec(source.slice(i));
|
||||
if (identifier) {
|
||||
tokens.push({ kind: "id", text: identifier[0] });
|
||||
i += identifier[0].length;
|
||||
continue;
|
||||
}
|
||||
const op = OPERATORS.find((candidate) => source.startsWith(candidate, i));
|
||||
if (!op) throw new FormulaError(`읽을 수 없는 글자: ${ch}`);
|
||||
tokens.push({ kind: "op", text: op });
|
||||
i += op.length;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
type Node =
|
||||
| { type: "num"; value: Frac }
|
||||
| { type: "str"; value: string }
|
||||
| { type: "name"; name: string }
|
||||
| { type: "unary"; op: string; arg: Node }
|
||||
| { type: "binary"; op: string; left: Node; right: Node }
|
||||
| { type: "call"; name: string; args: Node[] };
|
||||
|
||||
const COMPARISONS = new Set(["=", "<>", "<", "<=", ">", ">="]);
|
||||
|
||||
/** 우선순위는 엑셀과 같음 — 부호 > `^` > `* /` > `+ -` > 비교. 같은 단은 왼쪽부터. */
|
||||
function parse(source: string): Node {
|
||||
const tokens = tokenize(source);
|
||||
let at = 0;
|
||||
const peek = (): Token | undefined => tokens[at];
|
||||
const isOp = (text: string): boolean => peek()?.kind === "op" && peek()?.text === text;
|
||||
const expect = (text: string): void => {
|
||||
if (!isOp(text)) throw new FormulaError(`「${text}」가 있어야 함`);
|
||||
at += 1;
|
||||
};
|
||||
|
||||
const comparison = (): Node => {
|
||||
let left = additive();
|
||||
while (peek()?.kind === "op" && COMPARISONS.has(peek()!.text)) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: additive() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const additive = (): Node => {
|
||||
let left = term();
|
||||
while (isOp("+") || isOp("-")) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: term() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const term = (): Node => {
|
||||
let left = exponent();
|
||||
while (isOp("*") || isOp("/")) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: exponent() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const exponent = (): Node => {
|
||||
let left = unary();
|
||||
while (isOp("^")) {
|
||||
at += 1;
|
||||
left = { type: "binary", op: "^", left, right: unary() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const unary = (): Node => {
|
||||
if (isOp("-") || isOp("+")) {
|
||||
const op = tokens[at++].text;
|
||||
return { type: "unary", op, arg: unary() };
|
||||
}
|
||||
return primary();
|
||||
};
|
||||
const primary = (): Node => {
|
||||
const token = peek();
|
||||
if (!token) throw new FormulaError("식이 중간에 끝남");
|
||||
at += 1;
|
||||
if (token.kind === "num") return { type: "num", value: parseDecimal(token.text) };
|
||||
if (token.kind === "str") return { type: "str", value: token.text };
|
||||
if (token.kind === "id") {
|
||||
if (!isOp("(")) return { type: "name", name: token.text };
|
||||
at += 1;
|
||||
const args: Node[] = [];
|
||||
if (!isOp(")")) {
|
||||
args.push(comparison());
|
||||
while (isOp(",")) {
|
||||
at += 1;
|
||||
args.push(comparison());
|
||||
}
|
||||
}
|
||||
expect(")");
|
||||
return { type: "call", name: token.text, args };
|
||||
}
|
||||
if (token.text === "(") {
|
||||
const inner = comparison();
|
||||
expect(")");
|
||||
return inner;
|
||||
}
|
||||
throw new FormulaError(`여기에 올 수 없음: ${token.text}`);
|
||||
};
|
||||
|
||||
const tree = comparison();
|
||||
if (at < tokens.length) throw new FormulaError(`식 끝에 남은 글: ${tokens[at].text}`);
|
||||
return tree;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 풀이 */
|
||||
|
||||
type Value = Frac | string | boolean;
|
||||
|
||||
/** 안 선 줄·오류 난 줄을 가리키는 이름 — 쓰이는 순간 그 까닭으로 막음. */
|
||||
class Blocked {
|
||||
constructor(readonly message: string) {}
|
||||
}
|
||||
|
||||
interface Scope {
|
||||
names: Map<string, Value | Blocked>;
|
||||
tables: Record<string, LookupTable>;
|
||||
}
|
||||
|
||||
const isFrac = (v: Value): v is Frac => typeof v === "object";
|
||||
|
||||
function asFrac(value: Value): Frac {
|
||||
if (isFrac(value)) return value;
|
||||
if (typeof value === "boolean") return frac(value ? 1n : 0n);
|
||||
throw new FormulaError(`수 자리에 글이 옴: ${value}`);
|
||||
}
|
||||
|
||||
function truthy(value: Value): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (isFrac(value)) return value.n !== 0n;
|
||||
throw new FormulaError(`조건 자리에 글이 옴: ${value}`);
|
||||
}
|
||||
|
||||
function compareValues(a: Value, b: Value): number {
|
||||
if (typeof a === "string" || typeof b === "string") {
|
||||
if (typeof a !== "string" || typeof b !== "string") {
|
||||
throw new FormulaError("글과 수를 견줄 수 없음");
|
||||
}
|
||||
return a === b ? 0 : a < b ? -1 : 1;
|
||||
}
|
||||
return cmp(asFrac(a), asFrac(b));
|
||||
}
|
||||
|
||||
function lookup(scope: Scope, args: Node[], exact: boolean): Value {
|
||||
if (args.length !== 3) throw new FormulaError("LOOKUP 은 인자가 셋(표, 키, 열)");
|
||||
const [tableNode, keyNode, columnNode] = args;
|
||||
const tableName =
|
||||
tableNode.type === "name" || tableNode.type === "str"
|
||||
? tableNode.type === "name"
|
||||
? tableNode.name
|
||||
: tableNode.value
|
||||
: null;
|
||||
const table = tableName ? scope.tables[tableName] : undefined;
|
||||
if (!table) throw new FormulaError(`없는 표: ${tableName ?? "?"}`);
|
||||
const column = evaluate(columnNode, scope);
|
||||
const cells = typeof column === "string" ? table.columns[column] : undefined;
|
||||
if (!cells) throw new FormulaError(`표 ${tableName} 에 없는 열: ${String(column)}`);
|
||||
const key = evaluate(keyNode, scope);
|
||||
const keys = table.keys.map((item) => (typeof item === "number" ? toFrac(item) : item));
|
||||
let hit = -1;
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
if (i > 0 && compareValues(keys[i - 1], keys[i]) >= 0) {
|
||||
throw new FormulaError(`표 ${tableName} 의 키가 오름차순이 아님`);
|
||||
}
|
||||
const order = compareValues(keys[i], key);
|
||||
if (order === 0) hit = i;
|
||||
// 근사 일치 — 키 이하 중 가장 큰 줄(엑셀 `VLOOKUP(…,1)`).
|
||||
else if (order < 0 && !exact) hit = i;
|
||||
}
|
||||
if (hit < 0) throw new FormulaError(`표 ${tableName} 에서 키 ${String(key)} 를 못 찾음`);
|
||||
const cell = cells[hit];
|
||||
if (cell === null || cell === undefined || cell === "") {
|
||||
throw new FormulaError(`표 ${tableName} 의 칸이 비어 있음(원문 「-」)`);
|
||||
}
|
||||
return typeof cell === "number" ? toFrac(cell) : cell;
|
||||
}
|
||||
|
||||
function evaluate(node: Node, scope: Scope): Value {
|
||||
switch (node.type) {
|
||||
case "num":
|
||||
case "str":
|
||||
return node.value;
|
||||
case "name": {
|
||||
const value = scope.names.get(node.name);
|
||||
if (value === undefined) throw new FormulaError(`모르는 이름: ${node.name}`);
|
||||
// 안 선 줄·오류 난 줄은 **쓰일 때** 막음 — `when` 이 그 이름을 안 쓰면 줄은 그대로 판정됨.
|
||||
if (value instanceof Blocked) throw new FormulaError(value.message);
|
||||
return value;
|
||||
}
|
||||
case "unary": {
|
||||
const value = asFrac(evaluate(node.arg, scope));
|
||||
return node.op === "-" ? frac(-value.n, value.d) : value;
|
||||
}
|
||||
case "binary": {
|
||||
const left = evaluate(node.left, scope);
|
||||
const right = evaluate(node.right, scope);
|
||||
if (COMPARISONS.has(node.op)) {
|
||||
const order = compareValues(left, right);
|
||||
return {
|
||||
"=": order === 0,
|
||||
"<>": order !== 0,
|
||||
"<": order < 0,
|
||||
"<=": order <= 0,
|
||||
">": order > 0,
|
||||
">=": order >= 0,
|
||||
}[node.op] as boolean;
|
||||
}
|
||||
const a = asFrac(left);
|
||||
const b = asFrac(right);
|
||||
if (node.op === "+") return add(a, b);
|
||||
if (node.op === "-") return sub(a, b);
|
||||
if (node.op === "*") return mul(a, b);
|
||||
if (node.op === "/") return div(a, b);
|
||||
return power(a, b);
|
||||
}
|
||||
case "call": {
|
||||
const { name, args } = node;
|
||||
if (name === "IF") {
|
||||
if (args.length !== 3) throw new FormulaError("IF 는 인자가 셋(조건, 참, 거짓)");
|
||||
// 고른 갈래만 풂 — 안 고른 갈래의 오류(없는 표 칸 등)가 줄을 막지 않게.
|
||||
return evaluate(truthy(evaluate(args[0], scope)) ? args[1] : args[2], scope);
|
||||
}
|
||||
if (name === "LOOKUP") return lookup(scope, args, false);
|
||||
if (name === "LOOKUP_EXACT") return lookup(scope, args, true);
|
||||
const values = args.map((arg) => asFrac(evaluate(arg, scope)));
|
||||
if (name === "SQRT") {
|
||||
if (values.length !== 1) throw new FormulaError("SQRT 는 인자가 하나");
|
||||
return sqrt(values[0]);
|
||||
}
|
||||
if (name === "SUM") return values.reduce((total, v) => add(total, v), frac(0n));
|
||||
if (name === "MIN" || name === "MAX") {
|
||||
if (!values.length) throw new FormulaError(`${name} 에 인자가 없음`);
|
||||
return values.reduce((best, v) => (cmp(v, best) < 0 === (name === "MIN") ? v : best));
|
||||
}
|
||||
throw new FormulaError(`모르는 함수: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ROUNDING_MODES = new Set<string>([
|
||||
"floor",
|
||||
"trunc",
|
||||
"round",
|
||||
"ceil_away",
|
||||
"ceil",
|
||||
"none",
|
||||
"round_half_even",
|
||||
]);
|
||||
|
||||
/** `{제원}` 을 제원 값으로 — 모르는 이름은 그대로 두지 않고 오류(조인 키가 흔들림). */
|
||||
function fillSpec(spec: string, vars: Record<string, number | string>): string {
|
||||
return spec.replace(/\{([^{}]+)\}/g, (_, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) throw new FormulaError(`규격의 모르는 제원: ${key}`);
|
||||
return typeof value === "number" ? fracToString(toFrac(value)) : value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 장 한 벌을 줄 차례대로 풂. 오류는 **그 줄에만** 적고 다음 줄은 계속 풂 —
|
||||
* 오류 난 줄·안 선 줄을 가리키는 줄은 막힘(0 으로 때우지 않음, 명세 13장 Ⓐ).
|
||||
*/
|
||||
export function evaluateSheet(sheet: FormulaSheet): FormulaRowResult[] {
|
||||
const rows = [...(sheet.rows ?? [])].sort((a, b) => a.seq - b.seq);
|
||||
const done = new Map<number, { value: Frac | null; name: string; skipped: boolean }>();
|
||||
const results: FormulaRowResult[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const result: FormulaRowResult = {
|
||||
seq: row.seq,
|
||||
name: row.name,
|
||||
spec: row.spec ?? "",
|
||||
amount: null,
|
||||
raw: null,
|
||||
skipped: false,
|
||||
reason: null,
|
||||
error: null,
|
||||
};
|
||||
try {
|
||||
if (done.has(row.seq)) throw new FormulaError(`같은 차례 번호가 둘: ${row.seq}`);
|
||||
// ⛔ 갈 곳 기본값 없음 — 빠진 줄이 자재총괄에서 조용히 사라진 결함이 실재함(명세 13장 Ⓑ).
|
||||
if (!row.destination) throw new FormulaError("갈 곳(destination)이 없음");
|
||||
if (!(DESTINATIONS as readonly string[]).includes(row.destination)) {
|
||||
throw new FormulaError(`모르는 갈 곳: ${row.destination}`);
|
||||
}
|
||||
if (row.rounding && !ROUNDING_MODES.has(row.rounding.mode)) {
|
||||
throw new FormulaError(`모르는 반올림: ${row.rounding.mode}`);
|
||||
}
|
||||
const vars = { ...(sheet.vars ?? {}), ...(row.vars ?? {}) };
|
||||
result.spec = fillSpec(row.spec ?? "", vars);
|
||||
const names = new Map<string, Value | Blocked>();
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
names.set(key, typeof value === "number" ? toFrac(value) : value);
|
||||
}
|
||||
for (const [key, seq] of Object.entries(row.refs ?? {})) {
|
||||
if (seq >= row.seq) throw new FormulaError(`앞 줄만 가리킬 수 있음: ${key} → ${seq}`);
|
||||
const target = done.get(seq);
|
||||
if (!target) throw new FormulaError(`없는 줄: ${key} → ${seq}`);
|
||||
names.set(
|
||||
key,
|
||||
target.value ??
|
||||
new Blocked(`앞 줄 ${seq}(${target.name}) ${target.skipped ? "안 섬" : "오류"}`),
|
||||
);
|
||||
}
|
||||
const scope: Scope = { names, tables: sheet.tables ?? {} };
|
||||
if (row.when && row.when.trim() && !truthy(evaluate(parse(row.when), scope))) {
|
||||
result.skipped = true;
|
||||
result.reason = `조건이 거짓: ${row.when}`;
|
||||
done.set(row.seq, { value: null, name: row.name, skipped: true });
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
let raw: Frac;
|
||||
if (row.formula && row.formula.trim()) {
|
||||
raw = asFrac(evaluate(parse(row.formula), scope));
|
||||
} else {
|
||||
// 고정형 — 박힌 값을 그대로(명세 13장 「양식형 ↔ 고정형」).
|
||||
if (row.amount === null || row.amount === undefined || row.amount === "") {
|
||||
throw new FormulaError("식도 값도 없음");
|
||||
}
|
||||
raw = toFrac(row.amount);
|
||||
}
|
||||
const value = applyRounding(raw, row.rounding);
|
||||
result.raw = fracToString(raw);
|
||||
result.amount = fracToString(value);
|
||||
done.set(row.seq, { value, name: row.name, skipped: false });
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
done.set(row.seq, { value: null, name: row.name, skipped: false });
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* =============================================================================
|
||||
* B08_Quantity_Formula_Node.ts
|
||||
* 구조물도 식 풀이를 **서버가** 돌리는 진입점 — [저장]·[확정] 때 정본으로 다시 풂.
|
||||
*
|
||||
* 풀이는 화면이 쓰는 `B08_Quantity_Formula.ts` 그대로(판정 Ⓐ, 2026-09-13) —
|
||||
* B06 `B06_Section_Server_Calc_Node.ts` 와 같은 본. 여기에는 계산이 없음.
|
||||
*
|
||||
* 실행: node <번들> <입력.json> <출력.json>
|
||||
* 입력 { sheets: FormulaSheet[] }
|
||||
* 출력 { sheets: FormulaRowResult[][] } — 입력 장 차례 그대로
|
||||
* 끝 코드: 0 성공 / 2 인자 오류
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { evaluateSheet, type FormulaSheet } from "./B08_Quantity_Formula";
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <번들> <입력.json> <출력.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { sheets?: FormulaSheet[] };
|
||||
const sheets = (input.sheets ?? []).map((sheet) => evaluateSheet(sheet));
|
||||
writeFileSync(outputPath, JSON.stringify({ sheets }));
|
||||
@@ -0,0 +1,473 @@
|
||||
"""B08 수량 화면의 **근거 사전** — 어느 숫자가 어디서 와서 어떻게 나왔나 (PLAN 8-36 ④).
|
||||
|
||||
⚠⚠ **개발 전용.** 사전은 `provenance_payload()` 를 거쳐 나가고, 개발환경이 아니면 `None`
|
||||
이라 응답에 칸 자체가 안 생긴다. 화면에서 숨기는 것이 아니라 **안 보내는 것**이다.
|
||||
|
||||
왜 이 파일인가
|
||||
「식」과 「원천」의 정답은 값을 낳는 엔진이 안다. 화면 TS 에 손으로 적어 두면 엔진을
|
||||
고칠 때 설명만 옛것으로 남는다. 엔진 옆(같은 폴더)에 두어 같이 눈에 들어오게 한다.
|
||||
|
||||
⚠ **열 단위로 적는다.** 토적표 한 장이 30열 × 200줄 = 6천 칸이라 칸마다 지으면 응답이
|
||||
붐는다. 줄마다 갈리는 것(측구 안분 폴백 사유 등)은 줄이 이미 `notes` 로 들고 있고,
|
||||
화면이 그것을 카드에 덧붙인다.
|
||||
|
||||
⚠ **토적표에는 `final`(최종) 열이 없다 — 억지로 붙이지 않았다.**
|
||||
이 표는 중간 장부다. 내역서로 나가는 값은 **토공집계표**에서 선다. 여섯 등급을
|
||||
한 장에 다 채우려고 아무 열에나 `final` 을 붙이면 「분류가 있다」는 거짓만 남는다.
|
||||
이 어긋남은 등급을 고칠 근거이므로 PLAN 8-36 ① 에 그대로 남긴다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_provenance import (
|
||||
TIER_CALC,
|
||||
TIER_FINAL,
|
||||
TIER_INPUT,
|
||||
TIER_STANDARD,
|
||||
TIER_SURVEY,
|
||||
ColumnProvenance,
|
||||
provenance_payload,
|
||||
sheet_provenance,
|
||||
)
|
||||
|
||||
#: 토량환산계수가 어디서 오는지 — 여러 열이 같은 문장을 쓰므로 한 벌로 둔다.
|
||||
_FACTOR_SOURCE = (
|
||||
"토량환산계수(다짐) — 기본값 `config_system_design.EARTHWORK_CONVERSION_FACTORS`, "
|
||||
"프로젝트가 고른 값이 있으면 산출 조건 패널의 값"
|
||||
)
|
||||
|
||||
#: 단면적 넷의 공통 원천. B06 이 낸 설계 단면을 **그대로** 읽는다(여기서 다시 안 짓는다).
|
||||
_SECTION_SOURCE = "B06 횡단 설계가 낸 측점별 단면적"
|
||||
|
||||
#: 평균단면적법 한 줄. 신규 문서 5장 「다. 공사수량의 산출」.
|
||||
_MEAN_AREA = "(앞 측점 단면적 + 이 측점 단면적) ÷ 2 × 두 측점 사이 거리"
|
||||
|
||||
|
||||
def _area(key: str, label: str, extra: str = "") -> ColumnProvenance:
|
||||
"""단면적 열 — B06 설계값을 그대로 옮긴 자리라 식이 없다."""
|
||||
return ColumnProvenance(
|
||||
key=key,
|
||||
label=label,
|
||||
tier=TIER_SURVEY,
|
||||
formula="설계가 낸 값을 그대로 읽음 (여기서 다시 계산하지 않음)",
|
||||
source=_SECTION_SOURCE + (f" · {extra}" if extra else ""),
|
||||
code="B08_Quantity_Engine_EarthworkTable.py:StationArea.from_design",
|
||||
)
|
||||
|
||||
|
||||
def _volume(key: str, label: str, area_label: str) -> ColumnProvenance:
|
||||
return ColumnProvenance(
|
||||
key=key,
|
||||
label=label,
|
||||
tier=TIER_CALC,
|
||||
formula=_MEAN_AREA.replace("단면적", area_label),
|
||||
source="첫 측점은 앞이 없어 비어 있음 (실무 토적표도 첫 줄 체적이 빈칸)",
|
||||
code="B08_Quantity_Engine_EarthworkTable.py:200 mean_volume",
|
||||
)
|
||||
|
||||
|
||||
def _adjusted(key: str, label: str, volume_label: str) -> ColumnProvenance:
|
||||
return ColumnProvenance(
|
||||
key=key,
|
||||
label=label,
|
||||
tier=TIER_CALC,
|
||||
formula=f"{volume_label} × 토량환산계수(다짐)",
|
||||
source=_FACTOR_SOURCE,
|
||||
code="B08_Quantity_Engine_EarthworkTable.py:210",
|
||||
)
|
||||
|
||||
|
||||
def earthwork_sheet() -> dict[str, Any]:
|
||||
"""토적표 한 장의 사전. 열 키는 화면 `EarthworkRow` 와 같은 낱말이라야 한다."""
|
||||
return sheet_provenance(
|
||||
[
|
||||
ColumnProvenance(
|
||||
key="chainage_m",
|
||||
label="측점",
|
||||
tier=TIER_SURVEY,
|
||||
formula="노선 시점에서 잰 이정(m). 화면은 NO.n+m 으로 적음",
|
||||
source="B05 종단이 놓은 측점 배치",
|
||||
code="B08_Quantity_Engine_EarthworkTable.py:StationArea.chainage_m",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="distance_m",
|
||||
label="거리",
|
||||
tier=TIER_CALC,
|
||||
formula="이 측점 이정 − 앞 측점 이정",
|
||||
source="B05 종단 측점 배치. 첫 줄은 앞이 없어 0",
|
||||
code="B08_Quantity_Engine_EarthworkTable.py:195",
|
||||
),
|
||||
_area("cut_soil_area_m2", "절토 토사 단면적"),
|
||||
_volume("cut_soil_volume_m3", "절토 토사 입적", "절토 토사 단면적"),
|
||||
_adjusted("cut_soil_adjusted_m3", "절토 토사 보정량", "절토 토사 입적"),
|
||||
_area("cut_rock_area_m2", "절토 암석 단면적", "암 갈래는 측점의 `cut_rock_kind`"),
|
||||
_volume("cut_rock_volume_m3", "절토 암석 입적", "절토 암석 단면적"),
|
||||
_adjusted("cut_rock_adjusted_m3", "절토 암석 보정량", "절토 암석 입적"),
|
||||
_area(
|
||||
"ditch_soil_area_m2",
|
||||
"측구터파기 토사 단면적",
|
||||
"지반 유형·암반 경계선으로 B06 이 가른 값. 가름이 없는 옛 저장분만 "
|
||||
"절토 토사:암 면적비로 안분하고 그 줄에 사유가 남음",
|
||||
),
|
||||
_volume("ditch_soil_volume_m3", "측구터파기 토사 입적", "측구 토사 단면적"),
|
||||
_adjusted("ditch_soil_adjusted_m3", "측구터파기 토사 보정량", "측구 토사 입적"),
|
||||
_area(
|
||||
"ditch_rock_area_m2",
|
||||
"측구터파기 암석 단면적",
|
||||
"위와 같은 가름값. 0.0 은 설계가 낸 「없음」이고 값 없음과 다름",
|
||||
),
|
||||
_volume("ditch_rock_volume_m3", "측구터파기 암석 입적", "측구 암석 단면적"),
|
||||
_adjusted("ditch_rock_adjusted_m3", "측구터파기 암석 보정량", "측구 암석 입적"),
|
||||
ColumnProvenance(
|
||||
key="adjusted_total_m3",
|
||||
label="보정량계",
|
||||
tier=TIER_CALC,
|
||||
formula="절토 토사 보정량 + 절토 암석 보정량 + 측구 토사 보정량 + 측구 암석 보정량",
|
||||
source="네 보정량의 합. 성토에 쓸 수 있는 양으로 환산한 뒤의 값",
|
||||
code="B08_Quantity_Engine_EarthworkTable.py:215",
|
||||
),
|
||||
_area("fill_area_m2", "성토 단면적"),
|
||||
_volume("fill_volume_m3", "성토 입적", "성토 단면적"),
|
||||
ColumnProvenance(
|
||||
key="diverted_m3",
|
||||
label="유용토",
|
||||
tier=TIER_CALC,
|
||||
formula="min(보정량계, 성토 입적)",
|
||||
source="그 측점에서 절취분과 성토분이 서로 만나는 몫",
|
||||
code="B08_Quantity_Engine_EarthworkTable.py:222",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="balance_m3",
|
||||
label="차인토량",
|
||||
tier=TIER_CALC,
|
||||
formula="보정량계 − 성토 입적",
|
||||
source="양수면 남는 흙(사토), 음수면 모자란 흙(객토)",
|
||||
code="B08_Quantity_Engine_EarthworkTable.py:223",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="cumulative_m3",
|
||||
label="누가토량",
|
||||
tier=TIER_CALC,
|
||||
formula="첫 줄부터 이 줄까지 차인토량을 더해 온 값",
|
||||
source="유토곡선(mass haul)의 세로축이 되는 값",
|
||||
code="B08_Quantity_Engine_EarthworkTable.py:225",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def summary_sheet() -> dict[str, Any]:
|
||||
"""토공집계표 — 토적표·사면표를 공종별 총량으로 모은 장.
|
||||
|
||||
⚠ **B08 에서 `final`(최종)이 처음 서는 자리다.** 토적표는 중간 장부였고, 내역서로
|
||||
나가는 값은 여기 「계」다. 다만 무대(소운반 20m)처럼 **집계에는 오르되 내역 줄이
|
||||
되지 않는** 줄이 있어, 그 줄의 「계」는 칸 등급 `excluded` 로 덮어쓴다(화면 배선).
|
||||
"""
|
||||
return sheet_provenance(
|
||||
[
|
||||
ColumnProvenance(
|
||||
key="group",
|
||||
label="구분",
|
||||
tier=TIER_STANDARD,
|
||||
formula="품셈 공종 갈래 이름을 그대로 씀 (흙깎기·성토·측구터파기…)",
|
||||
source="거창 실무 토공집계표 시트의 열 문구를 그대로 옮김",
|
||||
code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="item",
|
||||
label="공종",
|
||||
tier=TIER_STANDARD,
|
||||
formula="지반 갈래 이름 (토사·연암·발파암…)",
|
||||
source="갈래 수는 프로젝트 설정의 암 갈래 세트가 정함 — 코드에 안 박음",
|
||||
rule="암 총량을 설계자가 넣은 갈래 비율(%)로 나눠 줄을 만듦",
|
||||
code="B08_Quantity_Engine_EarthworkSummary.py:_rock_split",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="spec",
|
||||
label="규격",
|
||||
tier=TIER_STANDARD,
|
||||
formula="시공 방법 표기 (기계(굴삭기)·백호우…)",
|
||||
source="품셈 공종이 요구하는 규격. 암은 시공법(긁어내기/터뜨리기)이 갈림",
|
||||
code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="unit",
|
||||
label="단위",
|
||||
tier=TIER_STANDARD,
|
||||
formula="품셈 공종이 정한 단위 (㎥·㎡·주…)",
|
||||
source="단위가 다르면 내역 단가와 안 맞음 — 여기서 정하지 않고 품셈을 따름",
|
||||
code="B08_Quantity_Engine_EarthworkSummary.py:SummaryRow",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="amount",
|
||||
label="계",
|
||||
tier=TIER_FINAL,
|
||||
formula="토적표·사면표 총량 × 반영률(%)",
|
||||
source=(
|
||||
"토공은 토적표 합계, 사면은 사면표 합계. ⚠ 반영률은 법정값이 아니라 "
|
||||
"설계자가 넣는 값이고 기본 100 %"
|
||||
),
|
||||
rule="무대(소운반 20m)는 집계에는 오르되 내역 줄이 아님 — 그 줄은 「제외」로 섬",
|
||||
code="B08_Quantity_Engine_EarthworkSummary.py:build_table",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def haul_sheet() -> dict[str, Any]:
|
||||
"""운반거리 — (운반수단 × 지반유형)별 가중평균 줄.
|
||||
|
||||
⚠⚠ **상태가 둘이다.** 거리는 다짐상태로 재고, 내역에 오르는 수량만 자연상태(÷C)로 낸다
|
||||
(설계실무 요령 5-4-3). 이 표의 「토량」은 **다짐상태**이므로 내역서 수량과 숫자가 다르다 —
|
||||
그 어긋남이 정상이라는 것을 카드가 말해 주어야 헛걸음을 안 한다.
|
||||
"""
|
||||
return sheet_provenance(
|
||||
[
|
||||
ColumnProvenance(
|
||||
key="equipment",
|
||||
label="운반수단",
|
||||
tier=TIER_CALC,
|
||||
formula="유토곡선이 띠마다 고른 수단 (무대·도자운반·덤프운반)",
|
||||
source="B06 운반계획(HaulPlan)의 띠. 여기서 다시 고르지 않음",
|
||||
code="B08_Quantity_Engine_HaulSummary.py:_legs_of",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="ground",
|
||||
label="지반유형",
|
||||
tier=TIER_CALC,
|
||||
formula="띠의 토량을 절토 구간 구성비로 안분한 세 갈래 (토사·리핑암·발파암)",
|
||||
source="B06 운반계획이 이미 안분해 둔 값",
|
||||
code="B08_Quantity_Engine_HaulSummary.py:_legs_of",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="volume_m3",
|
||||
label="토량",
|
||||
tier=TIER_CALC,
|
||||
formula="그 갈래에 속한 근거 구간들의 토량 합",
|
||||
source=(
|
||||
"⚠ **다짐상태**임. 내역서에 오르는 수량은 자연상태(÷토량환산계수)라 "
|
||||
"숫자가 다름 — 어긋난 것이 아님"
|
||||
),
|
||||
code="B08_Quantity_Engine_HaulSummary.py:summarize",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="average_distance_m",
|
||||
label="평균운반거리",
|
||||
tier=TIER_CALC,
|
||||
formula="Σ(토량 × 거리) ÷ Σ(토량) — 단순평균이 아님",
|
||||
source="실무 산출서가 「토량 × 거리」를 쌓아 나누는 그 식",
|
||||
code="B08_Quantity_Engine_HaulSummary.py:119 average_distance_m",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="legs",
|
||||
label="근거 구간",
|
||||
tier=TIER_CALC,
|
||||
formula="이 평균을 만든 구간의 개수",
|
||||
source="구간 줄은 버리지 않고 표 아래 근거로 함께 냄 — 되짚을 수 있어야 함",
|
||||
code="B08_Quantity_Engine_HaulSummary.py:190",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def preparation_sheet() -> dict[str, Any]:
|
||||
"""준비공·사방공 — **못 서는 줄도 서는 장.**
|
||||
|
||||
빈 표를 내면 「빠뜨린 것」과 「원래 없는 것」이 구별되지 않는다. 그래서 값이 없는 줄도
|
||||
상태와 사유를 달아 그대로 세운다. 값이 비어 있는 줄의 「수량」은 칸 등급 `blocked` 로
|
||||
덮어쓴다 — **근거가 오면 채워질 자리**이지 일부러 비운 자리가 아니다.
|
||||
"""
|
||||
return sheet_provenance(
|
||||
[
|
||||
ColumnProvenance(
|
||||
key="group",
|
||||
label="구분",
|
||||
tier=TIER_STANDARD,
|
||||
formula="준비공·사방공의 갈래 이름",
|
||||
source="품셈 9장(준비공)과 배치된 구조물 종류가 줄을 만듦",
|
||||
code="B08_Quantity_Engine_Preparation.py:build_table",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="item",
|
||||
label="공종",
|
||||
tier=TIER_STANDARD,
|
||||
formula="품셈 공종 이름 (표토제거·제근·임목파쇄…)",
|
||||
source="공종이 없으면 줄도 없음 — 화면에서 이름을 짓지 않음",
|
||||
code="B08_Quantity_Engine_Preparation.py:build_table",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="unit",
|
||||
label="단위",
|
||||
tier=TIER_STANDARD,
|
||||
formula="품셈 공종이 정한 단위",
|
||||
source="단위가 다르면 내역 단가와 안 맞음",
|
||||
code="B08_Quantity_Engine_Preparation.py:build_table",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="amount",
|
||||
label="수량",
|
||||
tier=TIER_CALC,
|
||||
formula="공종마다 다름 — 표토제거는 면적 × 표토 두께(T), 제근은 임목축적 등급",
|
||||
source=(
|
||||
"밑수는 사면표·구조물 목록이 내고, 두께·등급·개소는 산출 조건 패널에서 "
|
||||
"설계자가 넣음"
|
||||
),
|
||||
rule="넣어야 할 값이 비면 줄은 서되 수량이 「-」로 남고 사유가 붙음",
|
||||
code="B08_Quantity_Engine_Preparation.py:build_table",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="status",
|
||||
label="상태",
|
||||
tier=TIER_CALC,
|
||||
formula="값을 세웠나 못 세웠나",
|
||||
source="못 세운 줄은 옆 칸에 사유가 붙음 — 사유가 곧 무엇을 넣어야 하는지임",
|
||||
code="B08_Quantity_Engine_Preparation_Status.py",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def material_sheet() -> dict[str, Any]:
|
||||
"""자재총괄 — 구조물 원단위에서 자재별로 모은 장. 관급/사급을 줄마다 고른다."""
|
||||
return sheet_provenance(
|
||||
[
|
||||
ColumnProvenance(
|
||||
key="name",
|
||||
label="자재",
|
||||
tier=TIER_STANDARD,
|
||||
formula="품셈·카탈로그의 자재 이름",
|
||||
source="구조물 원단위의 성분 이름을 그대로 모음 — 여기서 이름을 짓지 않음",
|
||||
code="B08_Quantity_Engine_MaterialSummary.py",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="unit",
|
||||
label="단위",
|
||||
tier=TIER_STANDARD,
|
||||
formula="자재가 팔리는 단위 (㎥·본·kg…)",
|
||||
source="단가가 붙는 단위와 같아야 함",
|
||||
code="B08_Quantity_Engine_MaterialSummary.py",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="net_amount",
|
||||
label="순수량",
|
||||
tier=TIER_CALC,
|
||||
formula="구조물마다 낸 성분 수량의 합 (할증 전)",
|
||||
source="구조물 원단위 표의 「수량」을 자재 이름으로 모은 값",
|
||||
code="B08_Quantity_Engine_MaterialSummary.py",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="surcharge_pct",
|
||||
label="할증률",
|
||||
tier=TIER_STANDARD,
|
||||
formula="자재마다 정해진 할증률(%)",
|
||||
source="할증 판(dataset)이 정함. 판에 없는 자재는 「-」로 두고 지어내지 않음",
|
||||
code="B08_Quantity_Engine_MaterialSummary.py",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="total_amount",
|
||||
label="총수량",
|
||||
tier=TIER_FINAL,
|
||||
formula="순수량 × (1 + 할증률)",
|
||||
source="내역서·자재대로 나가는 값. 할증률이 없으면 순수량 그대로",
|
||||
code="B08_Quantity_Engine_MaterialSummary.py",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="supply",
|
||||
label="관급/사급",
|
||||
tier=TIER_INPUT,
|
||||
formula="설계자가 줄마다 고름",
|
||||
source="자재마다 갈리는 발주 결정이라 표 안에서 고름 (2026-09-07 확정)",
|
||||
code="B08_Quantity_UI_MaterialGrid.ts",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="install_by",
|
||||
label="설치 주체",
|
||||
tier=TIER_INPUT,
|
||||
formula="설계자가 줄마다 고름",
|
||||
source="⚠ **관급 줄에만 뜻이 있음** — 사급으로 되돌리면 값이 비워짐",
|
||||
code="B08_Quantity_UI_MaterialGrid.ts",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def unit_quantity_sheet() -> dict[str, Any]:
|
||||
"""구조물 원단위 — 치수에서 성분까지. 성분마다 갈 곳을 적는다.
|
||||
|
||||
⚠ 이 장은 **근거·출처 열을 이미 화면에 들고 있다**(2026-09-09 부터). 사전은 그 열이
|
||||
무엇을 뜻하는지 설명하는 자리이지, 있는 값을 다시 만드는 자리가 아니다.
|
||||
"""
|
||||
return sheet_provenance(
|
||||
[
|
||||
ColumnProvenance(
|
||||
key="structure",
|
||||
label="구조물",
|
||||
tier=TIER_SURVEY,
|
||||
formula="B05 노선에 놓인 구조물의 이름과 놓인 측점",
|
||||
source="측점 표기(NO.4 ~ NO.4+10)는 화면이 만듦 — 서버는 이정만 냄",
|
||||
code="B08_Quantity_Engine_Handoff_Rows_Prep.py:182",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="spec",
|
||||
label="규격",
|
||||
tier=TIER_SURVEY,
|
||||
formula="구조물 제원 (길이 × 높이)",
|
||||
source="B05·B06 이 배치할 때 정한 치수. 여기서 다시 정하지 않음",
|
||||
code="B08_Quantity_Engine_UnitQuantity.py",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="component",
|
||||
label="성분",
|
||||
tier=TIER_STANDARD,
|
||||
formula="그 구조물이 쓰는 재료·공종 이름",
|
||||
source="품셈 표 또는 실무 관측 원단위표가 정함",
|
||||
code="B08_Quantity_Engine_UnitQuantity.py",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="unit",
|
||||
label="단위",
|
||||
tier=TIER_STANDARD,
|
||||
formula="성분이 세어지는 단위",
|
||||
source="단가가 붙는 단위와 같아야 함",
|
||||
code="B08_Quantity_Engine_UnitQuantity.py",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="amount",
|
||||
label="수량",
|
||||
tier=TIER_CALC,
|
||||
formula="치수 전개(길이·높이로 편 식) 또는 실무 관측 원단위 × 개소",
|
||||
source="어느 쪽인지는 같은 줄의 「출처」 칸이 말해 줌 (치수 전개 / 실무 관측)",
|
||||
rule="치수 전개는 식이 있고, 실무 관측은 관측값이라 식이 없음 — 둘을 섞지 않음",
|
||||
code="B08_Quantity_Engine_UnitQuantity.py",
|
||||
),
|
||||
ColumnProvenance(
|
||||
key="destination",
|
||||
label="갈 곳",
|
||||
tier=TIER_STANDARD,
|
||||
formula="이 성분이 어느 표로 가는가 (자재총괄·공종 내역·양쪽)",
|
||||
source=(
|
||||
"⚠ 갈 곳이 겹치면 이중계상임 — 그것을 막으려고 성분마다 갈 곳을 적음 (PLAN 8-7)"
|
||||
),
|
||||
code="B08_Quantity_Engine_Handoff_Mapping.py",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def quantity_provenance() -> dict[str, Any] | None:
|
||||
"""B08 응답에 실을 사전 — **개발환경이 아니면 `None`.**
|
||||
|
||||
시트를 늘릴 때는 여기 한 줄만 더한다. 화면은 시트 이름으로 찾아 쓴다.
|
||||
"""
|
||||
return provenance_payload(
|
||||
{
|
||||
"earthwork": earthwork_sheet(),
|
||||
"summary": summary_sheet(),
|
||||
"haul": haul_sheet(),
|
||||
"preparation": preparation_sheet(),
|
||||
"material": material_sheet(),
|
||||
"unit_quantity": unit_quantity_sheet(),
|
||||
}
|
||||
)
|
||||
@@ -5,6 +5,7 @@
|
||||
구현할 때 이 라우터를 확장한다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
@@ -20,7 +21,25 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
|
||||
|
||||
@router.post("/{project_id}/quantity/confirm")
|
||||
async def confirm_quantity(project_id: UUID) -> JSONResponse:
|
||||
"""수량 단계 확정 — stage 5(QUANTITY)를 COMPLETE로 전이한다 (본문 미구현)."""
|
||||
"""수량 단계 확정 — stage 5(QUANTITY)를 COMPLETE로 전이한다 (본문 미구현).
|
||||
|
||||
⚠ 전이 전에 **아직 안 박힌 구조물도 양식을 프로젝트에 박음**(PLAN 4장) — 확정 뒤 프로그램
|
||||
기본을 고쳐도 확정한 값이 안 흔들리게. 못 박으면 확정하지 않음.
|
||||
"""
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import pin_program_templates
|
||||
from B08_Quantity.B08_Quantity_Router_StructureSheet import _project_root
|
||||
|
||||
project_root = await _project_root(project_id)
|
||||
try:
|
||||
if project_root is None:
|
||||
raise FileNotFoundError("프로젝트 저장 폴더 없음")
|
||||
await asyncio.to_thread(pin_program_templates, project_root)
|
||||
except Exception:
|
||||
logger.exception("B08 수량 확정 실패(양식 박기): project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "구조물도 양식을 프로젝트에 박지 못했습니다."},
|
||||
)
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
try:
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
값은 어디서 오나
|
||||
측점별 단면적은 **B06 이 이미 낸 정본**이다(`cross_sections.data.design` 의
|
||||
`cut_soil_area_m2`·`cut_rock_area_m2`·`fill_area_m2`·`ditch_area_m2`).
|
||||
`cut_soil_area_m2`·`cut_rock_area_m2`·`fill_area_m2`·`ditch_area_m2`, 그리고
|
||||
측구 가름값 `ditch_soil_area_m2`·`ditch_rock_area_m2`·`ditch_split_basis`).
|
||||
설계 dict 를 통째로 `StationArea.from_design` 에 넘기므로 키가 늘어도 여기는 안 고친다.
|
||||
B08 은 그것을 다시 재지 않고 **평균단면적법으로 체적화만** 한다.
|
||||
|
||||
계산 자리 (CLAUDE.md 5장)
|
||||
@@ -38,18 +40,24 @@ from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_pr
|
||||
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
|
||||
from B08_Quantity.B08_Quantity_Engine_SlopeArea import build_table as build_slope_table
|
||||
from B08_Quantity.B08_Quantity_Engine_SlopeLength import station_slopes
|
||||
from B08_Quantity.B08_Quantity_Provenance import quantity_provenance
|
||||
from common_util.common_util_project_settings import (
|
||||
CONCRETE_PLACING_METHODS,
|
||||
ROCK_METHODS,
|
||||
application_ratio,
|
||||
concrete_placing_method,
|
||||
earthwork_conversion_choices,
|
||||
earthwork_conversion_factors,
|
||||
quantity_settings,
|
||||
rock_classes,
|
||||
save_section,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import run_with_connection
|
||||
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS
|
||||
from config.config_system_design import (
|
||||
EARTHWORK_CONVERSION_FACTORS,
|
||||
EARTHWORK_CONVERSION_PUMSEM_C_RANGES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
|
||||
@@ -76,14 +84,24 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "토적표를 만들지 못했습니다."},
|
||||
)
|
||||
table = build_table(_stations(designs))
|
||||
# ⚠ 설정을 **먼저** 읽는다 — 토량환산계수를 프로젝트가 골랐으면 표가 그 값으로 서야 한다.
|
||||
settings, project_root = await _project_settings(project_id)
|
||||
factors = earthwork_conversion_factors(settings)
|
||||
table = build_table(_stations(designs), factors)
|
||||
# 화면이 「무엇을 골랐나 · 품셈 범위 안인가」를 보이는 데 쓴다. 계산에는 안 들어간다.
|
||||
table["conversion_factor_choices"] = earthwork_conversion_choices(settings)
|
||||
# 품셈 암종별 범위 — **화면 안내용**이다. 정의처가 서버 한 곳이라 내려보내 쓴다
|
||||
# (프론트에 다시 적으면 두 벌이 되어 갈린다).
|
||||
table["conversion_factor_pumsem_ranges"] = [
|
||||
{"name": name, "min": low, "max": high}
|
||||
for name, low, high in EARTHWORK_CONVERSION_PUMSEM_C_RANGES
|
||||
]
|
||||
# 사면 계열은 저장된 설계선에서 유도한다.
|
||||
slope = build_slope_table(station_slopes(designs))
|
||||
table["slope"] = slope
|
||||
|
||||
settings, project_root = await _project_settings(project_id)
|
||||
plan = await _stored_haul_plan(project_id, route_id)
|
||||
haul = build_haul_table(plan)
|
||||
haul = build_haul_table(plan, factors)
|
||||
# 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다).
|
||||
# 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다.
|
||||
# ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다.
|
||||
@@ -159,15 +177,22 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
|
||||
table["settings"] = settings
|
||||
table["project_root_known"] = project_root is not None
|
||||
table["route_id"] = route_id
|
||||
# 근거 사전(PLAN 8-36 ④) — ⚠ **개발환경에서만** 실린다. 운영에서는 `None` 이라
|
||||
# 칸 자체가 안 생긴다 — 화면에서 숨기는 것이 아니라 안 보내는 것이 요점이다.
|
||||
provenance = quantity_provenance()
|
||||
if provenance is not None:
|
||||
table["provenance"] = provenance
|
||||
return JSONResponse(content=table)
|
||||
|
||||
|
||||
#: 갈래 칸 ↔ 다짐 환산계수 `C`. 정의처는 `config_system_design` 한 곳뿐이다.
|
||||
_COMPACTED_FACTOR = {
|
||||
"ea_m3": float(EARTHWORK_CONVERSION_FACTORS["soil"]["compacted"]),
|
||||
"rr_m3": float(EARTHWORK_CONVERSION_FACTORS["ripping_rock"]["compacted"]),
|
||||
"br_m3": float(EARTHWORK_CONVERSION_FACTORS["blasting_rock"]["compacted"]),
|
||||
}
|
||||
#: 갈래 칸 ↔ 지반유형 이름. 계수의 정의처는 `config_system_design` 한 곳뿐이다.
|
||||
_GROUND_KIND_OF = {"ea_m3": "soil", "rr_m3": "ripping_rock", "br_m3": "blasting_rock"}
|
||||
|
||||
|
||||
def _compacted_factor(settings: dict[str, Any]) -> dict[str, float]:
|
||||
"""갈래 칸 ↔ 다짐 환산계수 `C` — 프로젝트가 고른 값이 있으면 그것이 선다."""
|
||||
factors = earthwork_conversion_factors(settings)
|
||||
return {key: float(factors[kind]["compacted"]) for key, kind in _GROUND_KIND_OF.items()}
|
||||
|
||||
|
||||
def _spoil_sites(designs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
@@ -293,10 +318,11 @@ def _spoil_of(
|
||||
# 여기서 ÷C 한 값을 함께 내 받는 쪽이 **또 환산하지 않게** 한다.
|
||||
# ⚠ 갈래를 못 붙인 몫은 계수가 없어 **환산하지 않는다** — 토사 계수로 눅이면 근거 없이
|
||||
# 금액이 움직인다. 그 사실을 사유로 낸다.
|
||||
compacted_factor = _compacted_factor(settings)
|
||||
natural_by_ground = {
|
||||
key: round(value / _COMPACTED_FACTOR[key], 3)
|
||||
key: round(value / compacted_factor[key], 3)
|
||||
for key, value in grounds.items()
|
||||
if key in _COMPACTED_FACTOR
|
||||
if key in compacted_factor
|
||||
}
|
||||
if unknown > 0:
|
||||
note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림")
|
||||
@@ -415,6 +441,10 @@ class QuantitySettingsBody(BaseModel):
|
||||
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
|
||||
wood_chipping_enabled: bool | None = None
|
||||
wood_chipping_volume_m3: float | None = None
|
||||
# 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`.
|
||||
# ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다.
|
||||
# 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
|
||||
conversion_factors_override: dict[str, Any] | None = None
|
||||
|
||||
|
||||
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
|
||||
@@ -456,6 +486,21 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
|
||||
method = values["concrete_placing_method"]
|
||||
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
|
||||
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None
|
||||
if "conversion_factors_override" in values:
|
||||
# 아는 갈래·양수만 남긴다. 사유는 값이 있을 때만 따라간다(계산에는 안 쓴다).
|
||||
cleaned: dict[str, Any] = {}
|
||||
for kind, entry in (values["conversion_factors_override"] or {}).items():
|
||||
if kind not in EARTHWORK_CONVERSION_FACTORS or not isinstance(entry, dict):
|
||||
continue
|
||||
value = entry.get("compacted")
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool) or float(value) <= 0:
|
||||
continue
|
||||
kept: dict[str, Any] = {"compacted": float(value)}
|
||||
reason = entry.get("reason")
|
||||
if isinstance(reason, str) and reason.strip():
|
||||
kept["reason"] = reason.strip()
|
||||
cleaned[kind] = kept
|
||||
values["conversion_factors_override"] = cleaned
|
||||
if "rock_methods" in values:
|
||||
# 「안 정함」(빈 값)은 저장하지 않는다 — 정한 것과 구별이 안 된다. 통째로 갈아 끼우므로
|
||||
# 여기서 버리면 그 갈래는 미지정으로 돌아간다.
|
||||
@@ -471,7 +516,14 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
|
||||
_save_quantity,
|
||||
root,
|
||||
values,
|
||||
("rock_methods", "material_supply", "concrete_placing_method", "ancillary_counts")
|
||||
(
|
||||
"rock_methods",
|
||||
"material_supply",
|
||||
"concrete_placing_method",
|
||||
"ancillary_counts",
|
||||
# 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다.
|
||||
"conversion_factors_override",
|
||||
)
|
||||
+ NULLABLE_SETTING_KEYS,
|
||||
)
|
||||
except Exception:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user