Merge remote-tracking branches 'origin/sub_desktop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_4' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -35,7 +35,7 @@ const guardContainer = (id: "inlet" | "outlet", title: string, link: string): Sp
|
||||
key: `${id}_type`,
|
||||
choices: guard(id === "inlet" ? "in" : "out"),
|
||||
},
|
||||
...bodyGuardItems(id, "본체 끝 높이(자동)"),
|
||||
...bodyGuardItems(id, "본체 끝 높이(자동)", true),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -83,19 +83,29 @@ export const revetKeys = (side: "inlet" | "outlet"): Record<string, string> => (
|
||||
});
|
||||
|
||||
/** 세월교 · 박스 유입 · 유출 흙막이 칸(79-7) — 높이 빈 칸 = 자동(`heightHint` · 횡단도가 셈) ·
|
||||
* 유입만 절토부 앞 수평공간 칸(`inlet_revet_berm_m` · 유출은 없음 · 89-4)을 더함. */
|
||||
export function bodyGuardItems(side: "inlet" | "outlet", heightHint: string): SpecItem[] {
|
||||
* 유입만 절토부 앞 수평공간 칸(`inlet_revet_berm_m` · 유출은 없음 · 89-4)을 더함 · `wingBerm` = 세월교
|
||||
* 유입 날개벽에도 같은 칸(수평 여유 · 107-3). */
|
||||
export function bodyGuardItems(
|
||||
side: "inlet" | "outlet",
|
||||
heightHint: string,
|
||||
wingBerm = false,
|
||||
): SpecItem[] {
|
||||
const items = guardItems({ 흙막이: revetKeys(side) }, undefined, true).map((item) =>
|
||||
item.id === "height" ? { ...item, default: undefined, hint: heightHint } : item,
|
||||
);
|
||||
if (side === "outlet") return items;
|
||||
const branch = { key: `${side}_revet_berm_m` };
|
||||
const berm: SpecItem = {
|
||||
id: "berm",
|
||||
label: "수평공간 (m)",
|
||||
kind: "number",
|
||||
step: 0.1,
|
||||
default: "1",
|
||||
by: { item: "structure", map: { 흙막이: { key: `${side}_revet_berm_m` } } },
|
||||
// 세월교 유입 날개벽 = 같은 칸 · 같은 키(끝이 묻히면 수평 여유 뒤 절토 · 면적 밖)
|
||||
by: {
|
||||
item: "structure",
|
||||
map: wingBerm ? { 흙막이: branch, 날개벽: branch } : { 흙막이: branch },
|
||||
},
|
||||
};
|
||||
return [...items, berm];
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ export function createFacilityOptionsForm(
|
||||
if (c.id === "inlet" && item.id === "structure" && inletIsAuto()) {
|
||||
const drawn = drawnNow();
|
||||
return drawn
|
||||
? { value: drawn.basin ? "집수정" : "기슭막이", tag: "자동" }
|
||||
? { value: drawn.basin ? "집수정" : "흙막이", tag: "자동" }
|
||||
: { value: "", tag: "자동(지형 규칙)" };
|
||||
}
|
||||
// 빈 고르기 칸 = 「선택 안 함」 그대로(54-39 · 기본값 꼬리 없음)
|
||||
@@ -412,7 +412,7 @@ export function createFacilityOptionsForm(
|
||||
// ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자
|
||||
// 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다
|
||||
// (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다).
|
||||
const extraGroup = group("추가 기슭막이");
|
||||
const extraGroup = group("추가 흙막이");
|
||||
const extraSlot = document.createElement("div");
|
||||
extraSlot.className = "b05-structure__adjust-slot";
|
||||
extraGroup.body.append(extraSlot);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const INLET_KINDS: ReadonlyArray<{
|
||||
type: "기슭막이" | "집수정";
|
||||
structure: InletStructureKind;
|
||||
}> = [
|
||||
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
|
||||
{ label: "흙막이", type: "기슭막이", structure: "revet" },
|
||||
{ label: "집수정", type: "집수정", structure: "auto" },
|
||||
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
|
||||
{ label: "집수정 I형", type: "집수정", structure: "I" },
|
||||
|
||||
@@ -262,6 +262,9 @@ export interface FordWingSpec {
|
||||
angle_deg: number | null;
|
||||
/** 바닥판 편측 연장(m) = 길이 × cos(각도). 각도는 관축 기준 벌어짐각. */
|
||||
slab_extend_m: number;
|
||||
/** 세월교 유입 날개벽 끝 수평 여유(m) — 흙막이 수평공간과 같은 칸(`inlet_revet_berm_m` · 107-3) ·
|
||||
* null = 화면 기본값(1 m). */
|
||||
berm_m?: number | null;
|
||||
}
|
||||
|
||||
/** 세월교 측점의 세트 제원 — 양측 ㄴ형 측벽 + 바닥판 + 관, 관 위는 성토 채움. */
|
||||
@@ -289,6 +292,8 @@ export interface FordSet {
|
||||
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
|
||||
export interface BoxSet {
|
||||
type: "box";
|
||||
/** 이 시설이 **놓인** 누가거리(m) — 걸친 옆 측점과 주인 측점을 가리는 열쇠(110). */
|
||||
chainage_m?: number;
|
||||
/** 사용자 입력 내공(유로) 폭·높이(m). */
|
||||
inner_width_m: number;
|
||||
inner_height_m: number;
|
||||
@@ -561,6 +566,9 @@ export interface CrossSectionPatch {
|
||||
cut_slope_ratio_user?: number;
|
||||
/** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */
|
||||
pipe_length_m?: number;
|
||||
/** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */
|
||||
pipe_slope_pct?: number;
|
||||
box_ground_slope_pct?: number;
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
basin_adjust?: {
|
||||
innerWidthM: number;
|
||||
|
||||
@@ -444,6 +444,9 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
pipe_count = max(int(count), 1) if count else 1
|
||||
wing_in = _wing_spec(values, defaults, "in")
|
||||
wing_out = _wing_spec(values, defaults, "out")
|
||||
if wing_in["installed"]:
|
||||
# 유입 날개벽 수평 여유 — 흙막이 수평공간과 같은 칸(107-3) · 빈 값 = 화면 기본값
|
||||
wing_in["berm_m"] = _number(values.get("inlet_revet_berm_m"), None)
|
||||
defaulted = _defaulted(
|
||||
values,
|
||||
[
|
||||
|
||||
@@ -184,6 +184,10 @@ class CrossSectionPatch(BaseModel):
|
||||
# 공종을 세려면 정본에 있어야 한다(2026-09-08). 계산은 서버가 같은 코드를 Node 로
|
||||
# 돌려 내므로 한 벌이다(`B06_Section_Server_Calc_Node`).
|
||||
pipe_length_m: float | None = Field(default=None, ge=0)
|
||||
# B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 기하가 낸 값
|
||||
# (관 길이와 같은 길). 0 · 음수도 그대로 — 빈칸 판정은 B08 몫.
|
||||
pipe_slope_pct: float | None = None
|
||||
box_ground_slope_pct: float | None = None
|
||||
|
||||
|
||||
class SectionConfirmRequest(BaseModel):
|
||||
|
||||
@@ -66,6 +66,8 @@ _AREA_KEYS = (
|
||||
"cut_soil_area_m2",
|
||||
"cut_rock_area_m2",
|
||||
"pipe_length_m",
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard";
|
||||
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
|
||||
import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
|
||||
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
@@ -68,16 +70,15 @@ const inletStructure: InletStructureControl = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`.
|
||||
* 그 시설의 **주인 측점** 누가거리 — 시설 자리(`target`)에서 가장 가까운 측점 하나. 자리를 모르면 `null`.
|
||||
*
|
||||
* ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고
|
||||
* **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다.
|
||||
*/
|
||||
function pipeOwnerChainage(
|
||||
section: CrossSection,
|
||||
function ownerChainage(
|
||||
target: number | undefined,
|
||||
sections: readonly CrossSection[],
|
||||
): number | null {
|
||||
const target = section.culvert?.chainage_m;
|
||||
if (typeof target !== "number") return null;
|
||||
let best: number | null = null;
|
||||
for (const item of sections) {
|
||||
@@ -155,8 +156,21 @@ export const STRUCTURE_ROW_KEYS = [
|
||||
"cut_soil_area_m2",
|
||||
"cut_rock_area_m2",
|
||||
"pipe_length_m",
|
||||
// B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 같은 길로 실음.
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
] as const;
|
||||
|
||||
/** 박스 두 끝(구체 끝) 원지반 경사(%) — 유입 − 유출 ÷ 수평거리. 본체는 늘 수평이라 원지반으로(110). */
|
||||
function boxGroundSlopePct(section: CrossSection, box: BoxLayout): number | null {
|
||||
const groundAt = groundInterpolator(section.samples);
|
||||
const inlet = box.sides.find((side) => side.wingRole === "inlet");
|
||||
const outlet = box.sides.find((side) => side.wingRole === "outlet");
|
||||
const run = inlet && outlet ? Math.abs(inlet.offset - outlet.offset) : 0;
|
||||
if (!groundAt || !inlet || !outlet || !(run > 0)) return null;
|
||||
return ((groundAt(inlet.offset) - groundAt(outlet.offset)) / run) * 100;
|
||||
}
|
||||
|
||||
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
|
||||
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
|
||||
const trim = trimOfLayouts(layouts);
|
||||
@@ -195,18 +209,26 @@ function areaRowOf(
|
||||
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
|
||||
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
|
||||
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
|
||||
const ownerChainage = pipeOwnerChainage(section, sections);
|
||||
const isOwner = (target: number | undefined): boolean => {
|
||||
const owner = ownerChainage(target, sections);
|
||||
return owner === null || Math.abs(owner - section.chainage_m) <= CHAINAGE_TOLERANCE_M;
|
||||
};
|
||||
// 세월교 · 박스 흙막이 세트(73-4)는 관이 아님 — 관 연장을 안 실음.
|
||||
const pipeOwner =
|
||||
!!section.culvert &&
|
||||
!section.culvert.body &&
|
||||
(ownerChainage === null ||
|
||||
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
|
||||
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
|
||||
const pipeRow: Record<string, number> | null =
|
||||
typeof pipeLengthM === "number" && pipeLengthM > 0
|
||||
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
|
||||
!!section.culvert && !section.culvert.body && isOwner(section.culvert.chainage_m);
|
||||
const pipe = pipeOwner ? layouts.culvert?.pipe : undefined;
|
||||
const ownRow: Record<string, number> = { chainage_m: section.chainage_m };
|
||||
if (pipe && pipe.lengthM > 0) {
|
||||
ownRow.pipe_length_m = Number(pipe.lengthM.toFixed(4));
|
||||
ownRow.pipe_slope_pct = Number(pipe.slopePct.toFixed(4));
|
||||
}
|
||||
// 박스도 걸친 옆 측점이 아니라 주인 측점에만(관과 같은 규칙).
|
||||
const boxSlope =
|
||||
layouts.box && isOwner(section.box?.chainage_m)
|
||||
? boxGroundSlopePct(section, layouts.box)
|
||||
: null;
|
||||
if (boxSlope !== null) ownRow.box_ground_slope_pct = Number(boxSlope.toFixed(4));
|
||||
const pipeRow = Object.keys(ownRow).length > 1 ? ownRow : null;
|
||||
const areaInput = areaInputOf(section, layouts);
|
||||
if (!areaInput) return pipeRow;
|
||||
const design = layouts.design;
|
||||
@@ -280,7 +302,7 @@ export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[]
|
||||
const rows: ExtraWallRow[] = [];
|
||||
for (const section of sections) {
|
||||
if (!section.culvert) continue;
|
||||
const owner = pipeOwnerChainage(section, sections);
|
||||
const owner = ownerChainage(section.culvert.chainage_m, sections);
|
||||
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
|
||||
const layouts = computeStoredLayouts(section, sections);
|
||||
const culvert = layouts?.culvert;
|
||||
|
||||
@@ -47,14 +47,13 @@ const EXTRA_ROWS = [
|
||||
".b06-structure-panel__struct--span",
|
||||
].join(", ");
|
||||
|
||||
/** 유입구·유출구·날개벽 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
|
||||
* 단 수 · 옵션 · 집수정 내공 · 세월교 측벽 높이(24-4a). */
|
||||
/** 유입구·유출구 칸은 폼에 없는 줄만 받는다 — 읽기(좌우 · 상하 · 구체 길이 · 표고) ·
|
||||
* 단 수 · 옵션 · 집수정 내공(24-4a). */
|
||||
const SIDE_ROWS = [
|
||||
".b06-structure-panel__struct--readout",
|
||||
".b06-structure-panel__struct--tier",
|
||||
".b06-structure-panel__struct--options",
|
||||
".b06-structure-panel__struct--basin-size",
|
||||
".b06-structure-panel__struct--wall-height",
|
||||
].join(", ");
|
||||
|
||||
/** 배관 칸 = 폼에 자리 없는 집수정 내공 · 유입 단 수만 — 목록에서 골라도 같은 글(54-29).
|
||||
@@ -64,12 +63,11 @@ const PIPE_INLET_ROWS = [
|
||||
".b06-structure-panel__struct--basin-size",
|
||||
].join(", ");
|
||||
const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size";
|
||||
const WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height";
|
||||
|
||||
/** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은
|
||||
* 세월교 측벽 높이(바닥 손 조정 · 91-2)만 받음(좌우 · 상하 · 바닥판 읽기는 칸에 안 둠 · 65-1). */
|
||||
* 받는 줄 없음(측벽 높이 줄 지움 · 107-3 · 읽기는 칸에 안 둠 · 65-1). */
|
||||
function rowsFor(slot: HTMLElement): string | null {
|
||||
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW;
|
||||
if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null;
|
||||
if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS;
|
||||
return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS;
|
||||
}
|
||||
|
||||
@@ -43,9 +43,11 @@ export interface BodyGuardBase {
|
||||
trim: CulvertDesignTrim;
|
||||
}
|
||||
|
||||
/** `defaults` = 세월교 측벽 조작을 안 본 기본 자리(관 1 m 맞춤 기준 · 107-1). */
|
||||
export function bodyGuardBase(
|
||||
section: CrossSection,
|
||||
samples: SectionSample[],
|
||||
defaults = false,
|
||||
): BodyGuardBase | null {
|
||||
const body = section.culvert?.body;
|
||||
const adjust = section.design;
|
||||
@@ -74,9 +76,10 @@ export function bodyGuardBase(
|
||||
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
|
||||
}
|
||||
if (body === "ford" && section.ford) {
|
||||
const stored = defaults ? null : adjust?.ford_adjust;
|
||||
const layout = computeFordLayout(section, samples, {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.outlet ?? {}) },
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
|
||||
});
|
||||
if (!layout) return null;
|
||||
// 세월교 끝 = 관 끝(측벽 바깥면) · 윗면 = 측벽 상단(상판 높이) · 바닥 = 바닥판 밑
|
||||
@@ -143,16 +146,21 @@ export function bodyGuardAxes(
|
||||
export type BodyAxes = { x: number; d: number; through: number; outward: number };
|
||||
|
||||
/** 세월교 관은 1 m 단위(80) — 흙막이 전면까지 든 관 길이를 올림하고 모자란 만큼 흙막이를 바깥으로
|
||||
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘. */
|
||||
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘.
|
||||
* 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 ·
|
||||
* 수량 L 은 그린 관 길이 올림 그대로). */
|
||||
export function fordPipeFit(
|
||||
base: BodyGuardBase | null,
|
||||
section: CrossSection,
|
||||
samples: SectionSample[],
|
||||
culvert: { body?: string | null; inlet: { structure?: string }; outlet: { structure?: string } },
|
||||
axesOf: (role: Role) => BodyAxes | null,
|
||||
axesOf: (role: Role, base?: BodyGuardBase | null) => BodyAxes | null,
|
||||
): [BodyAxes | null, BodyAxes | null] {
|
||||
const axes: [BodyAxes | null, BodyAxes | null] = [axesOf("inlet"), axesOf("outlet")];
|
||||
if (!base || culvert.body !== "ford") return axes;
|
||||
if (culvert.body !== "ford") return axes;
|
||||
const base = bodyGuardBase(section, samples, true);
|
||||
if (!base) return axes;
|
||||
const roles = ["inlet", "outlet"] as const;
|
||||
const guarded = roles.map((role, i) => (culvert[role].structure ? axes[i] : null));
|
||||
const guarded = roles.map((role) => (culvert[role].structure ? axesOf(role, base) : null));
|
||||
const tips = roles.map((role, i) => {
|
||||
const end = base.ends[role];
|
||||
const reach = guarded[i] ? guarded[i]!.outward * guarded[i]!.through : 0;
|
||||
@@ -163,8 +171,8 @@ export function fordPipeFit(
|
||||
const length = Math.hypot(tips[0].offset - tips[1].offset, tips[0].z - tips[1].z);
|
||||
const gap = Math.ceil(length - 1e-6) - length;
|
||||
const at = guarded[1] ? 1 : 0;
|
||||
const fit = guarded[at];
|
||||
if (!fit || gap < 1e-6) return axes;
|
||||
const fit = axes[at];
|
||||
if (!guarded[at] || !fit || gap < 1e-6) return axes;
|
||||
axes[at] = { ...fit, x: fit.x + gap };
|
||||
return axes;
|
||||
}
|
||||
|
||||
@@ -28,12 +28,40 @@ export type {
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** 고르는 부재 — 좌 · 우 끝 또는 본체(구체 가운데 1/3 · 좌측 「본체」 상자 · 66-1). */
|
||||
/** 고르는 부재 — 좌 · 우 끝 또는 본체(두 구체 끝 사이 · 좌측 「본체」 상자 · 66-1 · 108-3). */
|
||||
export type BoxPick = BoxSideRole | "body";
|
||||
|
||||
/** 선택 강조를 카드 재생성 없이 갈아 끼우는 setter. */
|
||||
export type BoxHighlightSetter = (role: BoxPick | null) => void;
|
||||
|
||||
/** 날개벽(에이프런) 없는 끝의 판정 띠 폭(m) — 구체 끝 바깥으로. */
|
||||
const BARE_END_HIT_M = 0.3;
|
||||
|
||||
/**
|
||||
* 고르기 판정면(108-3) — 본체 = 두 구체 끝 사이 · 끝 = 구체 끝(`side.offset`) ~ 에이프런 바깥
|
||||
* (`apron[1]`) · 에이프런이 없으면 구체 끝 바깥 얇은 띠. 높이 = 저판 밑 ~ 상판 위. 서로 겹치지 않는다.
|
||||
*/
|
||||
export function boxHitBands(layout: BoxLayout): Array<[BoxPick, OffsetPoint[]]> {
|
||||
const top = Math.max(...layout.topSlab.map((point) => point.elevation));
|
||||
const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation));
|
||||
const band = (from: number, to: number): OffsetPoint[] => [
|
||||
{ offset: from, elevation: top },
|
||||
{ offset: to, elevation: top },
|
||||
{ offset: to, elevation: bottom },
|
||||
{ offset: from, elevation: bottom },
|
||||
];
|
||||
return [
|
||||
["body", band(layout.sides[0].offset, layout.sides[1].offset)],
|
||||
...layout.sides.map((side): [BoxPick, OffsetPoint[]] => {
|
||||
const outward = side.role === "left" ? 1 : -1;
|
||||
const outer = side.apron.length
|
||||
? side.apron[1].offset
|
||||
: side.offset + outward * BARE_END_HIT_M;
|
||||
return [side.role, band(side.offset, outer)];
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다.
|
||||
* `onSelectSide`가 오면 좌·우 절반이 선택 대상이 된다(조정창이 그 측을 잡는다).
|
||||
@@ -172,27 +200,11 @@ export function appendBoxOverlay(
|
||||
label.textContent = layout.label.text;
|
||||
layer.append(label);
|
||||
|
||||
// 투명 겹면 — 구체 가운데 1/3 = 본체, 그 바깥 양쪽 = 그 끝(66-1). 어느 것을 조정할지 클릭으로 고른다.
|
||||
// 투명 겹면(108-3) — 본체 = 두 구체 끝 사이 · 끝 = 구체 끝 ~ 에이프런 바깥(날개벽 없으면 바깥
|
||||
// 얇은 띠) · 높이 저판 밑 ~ 상판 위. 어느 것을 조정할지 클릭으로 고른다.
|
||||
const hits = new Map<BoxPick, SVGPolygonElement>();
|
||||
if (onSelectSide) {
|
||||
const midOffset = (layout.sides[0].offset + layout.sides[1].offset) / 2;
|
||||
const third = (layout.sides[0].offset - layout.sides[1].offset) / 6;
|
||||
const top = Math.max(...layout.topSlab.map((point) => point.elevation));
|
||||
const bottom = Math.min(...layout.bottomSlab.map((point) => point.elevation));
|
||||
const band = (from: number, to: number): OffsetPoint[] => [
|
||||
{ offset: from, elevation: top },
|
||||
{ offset: to, elevation: top },
|
||||
{ offset: to, elevation: bottom },
|
||||
{ offset: from, elevation: bottom },
|
||||
];
|
||||
const parts: Array<[BoxPick, OffsetPoint[]]> = [
|
||||
["body", band(midOffset - third, midOffset + third)],
|
||||
...layout.sides.map((side): [BoxPick, OffsetPoint[]] => {
|
||||
const outer = side.apron.length ? side.apron[1].offset : side.offset;
|
||||
const inner = midOffset + Math.sign(side.offset - midOffset) * Math.abs(third);
|
||||
return [side.role, band(inner, outer)];
|
||||
}),
|
||||
];
|
||||
const parts: Array<[BoxPick, OffsetPoint[]]> = boxHitBands(layout);
|
||||
for (const [role, points] of parts) {
|
||||
const hit = polygon(
|
||||
points,
|
||||
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
bindArrowKeys,
|
||||
bindDragHandle,
|
||||
buildPanelShell,
|
||||
clampAxis,
|
||||
dpadButton,
|
||||
dragReadout,
|
||||
makeRow,
|
||||
snapAxisDrag,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
@@ -27,6 +29,8 @@ export interface BoxPanelDeps {
|
||||
nudgeLength: (role: BoxSideRole, screenDeltaM: number) => void;
|
||||
/** 그 끝의 구체 표고 ±0.1m. */
|
||||
nudgeRise: (role: BoxSideRole, deltaM: number) => void;
|
||||
/** 그림 끌기(108-2) — 길이(화면 좌 +) · 표고를 한 번에 반영. */
|
||||
move: (role: BoxSideRole, screenDeltaM: number, riseDeltaM: number) => void;
|
||||
/** 그 측을 자동 자리로 되돌린다. */
|
||||
reset: (role: BoxSideRole) => void;
|
||||
/** 이 측이 좌측 「구조물 배치」의 어느 칸인가 — 좌·우는 측점 지형이 정하고, 폼은
|
||||
@@ -48,9 +52,10 @@ export interface BoxPanelHandle {
|
||||
|
||||
/** 한 걸음(m) — 사용자 지정 0.1m. */
|
||||
const STEP_M = 0.1;
|
||||
const RISE_LIMIT_M = 10;
|
||||
|
||||
/** BOX암거 조정 오버레이 창을 만든다. 처음에는 숨어 있다. 값 칸은 없다(계획서 24-4a) —
|
||||
* 오버레이 = 읽기 + 십자 + 그림 끌기 손잡이 · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
|
||||
* 오버레이 = 제목 + 십자 + 그림 끌기 손잡이(108-1) · `opts.dock` = 좌측 폼 날개벽 칸으로 옮겨 붙는 읽기 줄. */
|
||||
export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): BoxPanelHandle {
|
||||
const shell = buildPanelShell(() => deps.close());
|
||||
const { root, title } = shell;
|
||||
@@ -63,7 +68,8 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
|
||||
const riseLine = document.createElement("span");
|
||||
const slopeLine = document.createElement("span");
|
||||
const dragLine = dragReadout();
|
||||
value.append(lengthLine, riseLine, slopeLine, dragLine);
|
||||
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
|
||||
value.append(...(opts?.dock ? [lengthLine, riseLine, slopeLine] : []), dragLine);
|
||||
|
||||
let current: BoxSideRole | null = null;
|
||||
const act = (run: (role: BoxSideRole) => void) => () => {
|
||||
@@ -106,17 +112,27 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
|
||||
|
||||
const off = { attach: (): void => undefined, detach: (): void => undefined };
|
||||
const arrows = opts?.dock ? off : bindArrowKeys(root, () => [moveRow.controls]);
|
||||
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고(십자와 같은 걸음 · 24-4a).
|
||||
// 그림 위 끌기 손잡이 — ◀▶ 구체 길이 · ▲▼ 표고 두 축 함께(십자와 같은 걸음 · 108-2).
|
||||
// 한계 = 길이 ≥ 0(바깥으로만) · 표고 ±10 m(제어기 `_Page_Ford_Controls` 와 같음).
|
||||
const drag = opts?.dock
|
||||
? off
|
||||
: bindDragHandle(root, {
|
||||
stepM: () => (current ? STEP_M : null),
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
commit: (left, down) => {
|
||||
if (!current) return;
|
||||
if (left) deps.nudgeLength(current, left * STEP_M);
|
||||
if (down) deps.nudgeRise(current, -down * STEP_M);
|
||||
snap: (leftM, downM) => {
|
||||
const role = current;
|
||||
if (!role) return null;
|
||||
const from = deps.adjustFor(role);
|
||||
const lengthSign = role === "left" ? 1 : -1;
|
||||
return snapAxisDrag(leftM, downM, {
|
||||
stepM: STEP_M,
|
||||
slopeOutward: null,
|
||||
clamp: (left, down) => [
|
||||
clampAxis(from.lengthM, left * lengthSign, 0, Infinity) * lengthSign,
|
||||
-clampAxis(from.riseM, -down, -RISE_LIMIT_M, RISE_LIMIT_M),
|
||||
],
|
||||
apply: (left, down) => deps.move(role, left, -down),
|
||||
});
|
||||
},
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
|
||||
@@ -124,14 +124,14 @@ export function appendCulvertOverlay(
|
||||
segment.points,
|
||||
`유출부 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
(segment.overLimit
|
||||
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
|
||||
? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)"
|
||||
: " (법정 5m 이내)") +
|
||||
` · 성토 물매 1:${(segment.ratio ?? layout.fillSlope.ratio).toFixed(2)}` +
|
||||
(layout.fillSlope.roadWideningM > 0.01
|
||||
? ` · 노폭 연장 ${layout.fillSlope.roadWideningM.toFixed(2)}m`
|
||||
: "") +
|
||||
(layout.fillSlope.structureRequired
|
||||
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
|
||||
? " · ⚠ 흙막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
@@ -148,7 +148,7 @@ export function appendCulvertOverlay(
|
||||
? `${basinFillName} 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
|
||||
: `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
(segment.overLimit
|
||||
? " (법정 5m 이상 — 기슭막이 의무 구간, 조정창에서 추가)"
|
||||
? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)"
|
||||
: " (법정 5m 이내)") +
|
||||
` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`,
|
||||
);
|
||||
@@ -192,7 +192,7 @@ export function appendCulvertOverlay(
|
||||
"b06-chart__culvert-revet",
|
||||
// 높이 표기 = 순수 높이(바닥~상단, 근입 0.5 포함). 추가 기슭막이도 같은 기준으로
|
||||
// 통일했다(2026-08-23 배관 벽 → 2026-08-29 추가 벽).
|
||||
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
|
||||
`${roleLabel(wall.role)} 흙막이 ${wall.form ?? ""} H=${(
|
||||
wall.height + REVET_EMBED_DEPTH_M
|
||||
).toFixed(1)}m` +
|
||||
`(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사))` +
|
||||
@@ -221,8 +221,8 @@ export function appendCulvertOverlay(
|
||||
basin.reason === "cut"
|
||||
? "절토측 유입 — 집수정 기본(성토사면 없음)"
|
||||
: basin.reason === "manual"
|
||||
? "사용자 선택 집수정(규칙상 기슭막이 자리)"
|
||||
: "유입측 성토사면 3m 이하 — 기슭막이 대신 집수정 기본";
|
||||
? "사용자 선택 집수정(규칙상 흙막이 자리)"
|
||||
: "유입측 성토사면 3m 이하 — 흙막이 대신 집수정 기본";
|
||||
for (const part of basin.parts) {
|
||||
const shape = polygon(
|
||||
part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
|
||||
@@ -344,7 +344,7 @@ export function appendCulvertOverlay(
|
||||
const hit = polygon(
|
||||
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
|
||||
"b06-chart__culvert-revet-hit",
|
||||
`${roleLabel(wall.role)} 기슭막이 선택 — 카드 하단 ◀/▶로 자리 조절`,
|
||||
`${roleLabel(wall.role)} 흙막이 선택 — 카드 하단 ◀/▶로 자리 조절`,
|
||||
);
|
||||
hit.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface OutletExtrasInput {
|
||||
}
|
||||
|
||||
/** 수평공간(berm) 뒤 절토(복합경사 · 91-4)로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */
|
||||
function bermCut(
|
||||
export function bermCut(
|
||||
src: OffsetPoint,
|
||||
outward: number,
|
||||
berm: { lengthM: number; cut: StructureCutSpec },
|
||||
|
||||
@@ -172,8 +172,8 @@ export function computeCulvertLayout(
|
||||
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
|
||||
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
|
||||
// 본체 끝 자리(x 0점 · d 기본값). 본체 없으면(연동 옆 측점) 배관 규칙.
|
||||
const bodyAxesOf = (role: "inlet" | "outlet") => {
|
||||
const end = body?.ends[role];
|
||||
const bodyAxesOf = (role: "inlet" | "outlet", base = body) => {
|
||||
const end = base?.ends[role];
|
||||
const [spec, info] =
|
||||
role === "inlet" ? [inletWallSpec, inletInfo] : [outletWallSpec, outletInfo];
|
||||
if (!end) return null;
|
||||
@@ -186,7 +186,7 @@ export function computeCulvertLayout(
|
||||
thickness: REVET_THICKNESS_M,
|
||||
});
|
||||
};
|
||||
const [inletBody, outletBody] = fordPipeFit(body, culvert, bodyAxesOf);
|
||||
const [inletBody, outletBody] = fordPipeFit(section, groundSamples, culvert, bodyAxesOf);
|
||||
const onBody = (adjust: WallAdjust, axes: { x: number; d: number } | null): WallAdjust =>
|
||||
axes ? { ...adjust, x: axes.x + adjust.x, d: adjust.d ?? axes.d } : adjust;
|
||||
const minX = culvert.body ? Number.NEGATIVE_INFINITY : undefined;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch";
|
||||
import { pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { GUARD_CUT_BERM_M, pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type {
|
||||
BasinLayout,
|
||||
CulvertDesignTrim,
|
||||
@@ -25,12 +25,13 @@ import type {
|
||||
OutletFillSegment,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { basinApproachPoints, buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin";
|
||||
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { bermCut, buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import { structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut";
|
||||
|
||||
/** 측벽 한 매의 조작값 — 집수정 9키와 같은 축(높이·좌우·상하 대각). */
|
||||
export interface FordWallAdjust {
|
||||
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 자동(바닥판이 계류 하상에 놓임). */
|
||||
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 기본 = 관경 + 최소 토피(원지반 안 봄 · 107-2). */
|
||||
heightM: number | null;
|
||||
/** 좌우(m, + = 노견 바깥). 안쪽으로는 못 간다. */
|
||||
lateralM: number;
|
||||
@@ -103,7 +104,8 @@ export interface FordLayout {
|
||||
adjust: FordAdjust;
|
||||
}
|
||||
|
||||
/** 측벽 높이 하한 = 관경 + 최소 토피. 관이 노면 위로 솟지 않게 막는다. */
|
||||
/** 측벽 높이 하한 · 기본 = 관경 + 최소 토피 — 바닥 = 노면 − (관경 + 토피)(박스 「노면 − 복토」 꼴 ·
|
||||
* 원지반 안 봄 · 깊은 계류면 ▲▼ 로 내림 · 107-2). 관이 노면 위로 솟지 않게 막는다. */
|
||||
function minWallHeight(ford: FordSet): number {
|
||||
return ford.diameter_m + ford.min_cover_m;
|
||||
}
|
||||
@@ -149,15 +151,6 @@ export function computeFordLayout(
|
||||
const inletOnLeft = uphill === "left";
|
||||
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
|
||||
|
||||
// 바닥판이 놓일 계류 하상 — 두 노견 사이 원지반 최저점.
|
||||
const bedElevation = (() => {
|
||||
let low = Math.min(groundAt(edges.left.offset_m), groundAt(edges.right.offset_m));
|
||||
for (let offset = edges.right.offset_m; offset <= edges.left.offset_m; offset += 0.25) {
|
||||
low = Math.min(low, groundAt(offset));
|
||||
}
|
||||
return low;
|
||||
})();
|
||||
|
||||
const buildSide = (role: FordWallRole): FordSideLayout | null => {
|
||||
const onLeft = role === "inlet" ? inletOnLeft : !inletOnLeft;
|
||||
const edge = onLeft ? edges.left : edges.right;
|
||||
@@ -166,8 +159,7 @@ export function computeFordLayout(
|
||||
const wing = role === "inlet" ? ford.wing_in : ford.wing_out;
|
||||
const wanted = role === "inlet" ? adjust.inlet : adjust.outlet;
|
||||
const lateralM = Math.max(wanted.lateralM, 0);
|
||||
const autoHeight = roadTopAt(edge.offset_m) - (bedElevation + ford.slab_thickness_m);
|
||||
const heightM = Math.max(wanted.heightM ?? autoHeight, minWallHeight(ford));
|
||||
const heightM = Math.max(wanted.heightM ?? minWallHeight(ford), minWallHeight(ford));
|
||||
// 벽 상단이 노견에 붙도록 바닥판 상면을 역산한다. 상하 이동은 buildBasin이 얹는다.
|
||||
const anchor: OffsetPoint = {
|
||||
offset: edge.offset_m,
|
||||
@@ -235,7 +227,20 @@ export function computeFordLayout(
|
||||
);
|
||||
})(),
|
||||
fillSegments: extras.segments,
|
||||
cutLine: built.basin.cutLine,
|
||||
// 유입 날개벽 끝이 묻히면 수평 여유 뒤 절토 — 흙막이 수평공간과 같은 칸 · 기본 1 m(107-3)
|
||||
cutLine:
|
||||
role === "inlet" && wing.installed && built.basin.cutLine
|
||||
? bermCut(
|
||||
floor.points[1],
|
||||
outward,
|
||||
{
|
||||
lengthM: Math.max(wing.berm_m ?? GUARD_CUT_BERM_M, 0),
|
||||
cut: structureCutSpec(section.design),
|
||||
},
|
||||
groundAt,
|
||||
limitOffset,
|
||||
).points
|
||||
: built.basin.cutLine,
|
||||
adjust: {
|
||||
heightM: wanted.heightM === null ? null : heightM,
|
||||
lateralM,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* `_Cross_Panel_Base.ts`)을 쓴다 — 머리줄(제목·닫기), 2행 항목 구조,
|
||||
* **십자 조작 세트는 창 맨 아래**, 키보드 방향키 제어 포함(2026-08-25 일원화).
|
||||
*
|
||||
* 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이는 폼 날개벽
|
||||
* 칸으로 옮겨 붙는 줄 · 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a).
|
||||
* 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이 줄 없음(107-3) ·
|
||||
* 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a).
|
||||
* 바닥판 연장은 날개벽 길이×cos(각도)가 정하므로 읽기 전용으로 보여 준다.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
bindArrowKeys,
|
||||
bindDragHandle,
|
||||
buildPanelShell,
|
||||
clampAxis,
|
||||
dpadButton,
|
||||
dragReadout,
|
||||
makeButton,
|
||||
makeRow,
|
||||
snapAxisDrag,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
@@ -29,12 +30,14 @@ export interface FordPanelDeps {
|
||||
adjustFor: (role: FordWallRole) => FordWallAdjust;
|
||||
/** 실제 그려진 벽 높이(m) — 자동일 때도 숫자를 보여 준다. */
|
||||
heightFor: (role: FordWallRole) => number;
|
||||
/** 높이 ±0.1m. */
|
||||
nudgeHeight: (role: FordWallRole, deltaM: number) => void;
|
||||
/** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */
|
||||
nudge: (role: FordWallRole, screenDeltaM: number) => void;
|
||||
/** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
|
||||
nudgeSlope: (role: FordWallRole, deltaM: number) => void;
|
||||
/** 사면 바깥이 화면 왼쪽이면 1 · 오른쪽이면 −1(끌기 축 나누기 · 108-2). */
|
||||
outwardOf: (role: FordWallRole) => number;
|
||||
/** 그림 끌기 — 좌우(화면 좌 +) · 상하(사면) 를 한 번에 반영. */
|
||||
move: (role: FordWallRole, screenDeltaM: number, slopeDeltaM: number) => void;
|
||||
/** 자동 자리로 되돌린다(세 축 모두). */
|
||||
reset: (role: FordWallRole) => void;
|
||||
/** 바닥판 길이(m) — 날개벽 투영이 정한 값을 읽기 전용으로 보여 준다. */
|
||||
@@ -51,11 +54,13 @@ export interface FordPanelHandle {
|
||||
|
||||
/** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */
|
||||
const STEP_M = 0.1;
|
||||
/** 좌우 · 상하 한계(m) — 제어기 `clampMove`(`_Page_Ford_Controls`)와 같다. */
|
||||
const MOVE_LIMIT_M = 10;
|
||||
|
||||
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다.
|
||||
* 관경 · 수량 · 날개벽은 좌측 폼 칸이 정본이라 창에 두지 않는다(계획서 24-4a).
|
||||
* `opts.dock` — 좌측 폼에 옮겨 붙을 줄(읽기 · 측벽 높이)만 만든다 — 폼에 없던 칸이다.
|
||||
* 오버레이는 읽기 + 십자 + 그림 끌기 손잡이. */
|
||||
* `opts.dock` — 읽기 줄만 만든다(측벽 높이 줄 없음 · 107-3).
|
||||
* 오버레이는 제목 + 십자 + 그림 끌기 손잡이(108-1). */
|
||||
export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): FordPanelHandle {
|
||||
const shell = buildPanelShell(() => deps.close());
|
||||
const { root, title } = shell;
|
||||
@@ -68,33 +73,14 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
const moveLine = document.createElement("span");
|
||||
const slabLine = document.createElement("span");
|
||||
const dragLine = dragReadout();
|
||||
value.append(heightLine, moveLine, slabLine, dragLine);
|
||||
// 횡단도 안 창 = 제목 · 단추 · 끄는 중 미리 보기만(108-1) — 읽기 줄은 좌측 사본에만.
|
||||
value.append(...(opts?.dock ? [heightLine, moveLine, slabLine] : []), dragLine);
|
||||
|
||||
let current: FordWallRole | null = null;
|
||||
const act = (run: (role: FordWallRole) => void) => () => {
|
||||
if (current) run(current);
|
||||
};
|
||||
|
||||
// 측벽 높이 — 폼에 칸이 없던 값이라 폼 날개벽 칸으로 옮겨 붙는 줄(24-4a).
|
||||
const heightRow = makeRow("측벽 높이");
|
||||
heightRow.row.classList.add("b06-structure-panel__struct--wall-height");
|
||||
const heightLabel = heightRow.row.querySelector<HTMLElement>(".b06-structure-panel__label")!;
|
||||
const heightValue = document.createElement("span");
|
||||
heightValue.className = "b06-structure-panel__hval";
|
||||
heightRow.controls.append(
|
||||
makeButton(
|
||||
"−",
|
||||
"측벽 높이 0.1m 낮추기",
|
||||
act((role) => deps.nudgeHeight(role, -STEP_M)),
|
||||
),
|
||||
heightValue,
|
||||
makeButton(
|
||||
"+",
|
||||
"측벽 높이 0.1m 높이기",
|
||||
act((role) => deps.nudgeHeight(role, STEP_M)),
|
||||
),
|
||||
);
|
||||
|
||||
// 십자 조작 세트 — **창 맨 아래**(템플릿 규약).
|
||||
const moveRow = makeRow("이동");
|
||||
moveRow.controls.classList.add("b06-structure-panel__buttons");
|
||||
@@ -126,22 +112,34 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
),
|
||||
);
|
||||
|
||||
root.append(value, opts?.dock ? heightRow.row : moveRow.row);
|
||||
// 측벽 높이 줄 없음 — 세월교 바닥은 노면 − (관경 + 토피) 기본(107-3)
|
||||
root.append(value);
|
||||
if (!opts?.dock) root.append(moveRow.row);
|
||||
|
||||
const arrows = opts?.dock
|
||||
? { attach: (): void => undefined, detach: (): void => undefined }
|
||||
: bindArrowKeys(root, () => [moveRow.controls]);
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a).
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 1:1.2 사면 두 축 함께(108-2).
|
||||
// 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 상하 ±10 m.
|
||||
const drag = opts?.dock
|
||||
? { attach: (): void => undefined, detach: (): void => undefined }
|
||||
: bindDragHandle(root, {
|
||||
stepM: () => (current ? STEP_M : null),
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
commit: (left, down) => {
|
||||
if (!current) return;
|
||||
if (left) deps.nudge(current, left * STEP_M);
|
||||
if (down) deps.nudgeSlope(current, down * STEP_M);
|
||||
snap: (leftM, downM) => {
|
||||
const role = current;
|
||||
if (!role) return null;
|
||||
const from = deps.adjustFor(role);
|
||||
const outward = deps.outwardOf(role);
|
||||
return snapAxisDrag(leftM, downM, {
|
||||
stepM: STEP_M,
|
||||
slopeOutward: outward,
|
||||
clamp: (left, down) => [
|
||||
clampAxis(from.lateralM, left * outward, 0, MOVE_LIMIT_M) * outward,
|
||||
clampAxis(from.slopeM, down, -MOVE_LIMIT_M, MOVE_LIMIT_M),
|
||||
],
|
||||
apply: (left, down) => deps.move(role, left, down),
|
||||
});
|
||||
},
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
@@ -153,9 +151,6 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
`높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : "");
|
||||
moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`;
|
||||
slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`;
|
||||
heightValue.textContent = `${deps.heightFor(current).toFixed(1)}m`;
|
||||
// 자동 = 바닥판이 계류 하상을 따라감 · 손 값 = 원지반이 바뀌어도 그대로(91-2) — 값 칸이 좁아 이름표에
|
||||
heightLabel.textContent = adjust.heightM === null ? "측벽 높이 (자동)" : "측벽 높이";
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* · 키보드 방향키 = 십자 버튼(2026-08-23) — 셀렉트·입력 포커스 중엔 양보.
|
||||
* ========================================================================== */
|
||||
|
||||
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
/** 조작 버튼 하나 — 클릭이 카드 선택·팬으로 번지지 않게 막는다. */
|
||||
export function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
@@ -89,19 +91,77 @@ export function arrowsBelongToProfile(): boolean {
|
||||
/** 고른 구조물의 도형 — 벽 · 집수정 · 측벽은 `culvert` 계열, BOX 끝은 판정면이 켜진다. */
|
||||
const ACTIVE_SHAPES = '[class*="culvert"].is-active, .b06-chart__box-hit.is-active';
|
||||
|
||||
/** 끌기 손잡이가 조정 창에 묻는 것 — 걸음(m)이 null 이면 지금 고른 것은 끌 수 없다. */
|
||||
/** 끈 자리를 패널 걸음 · 한계로 맞춘 결과 — 화면 이동(m · 왼쪽 + · 아래 +) · 미리 보기 글 · 반영. */
|
||||
export interface DragSnap {
|
||||
leftM: number;
|
||||
downM: number;
|
||||
text: string;
|
||||
apply: () => void;
|
||||
}
|
||||
|
||||
/** 끌기 손잡이가 조정 창에 묻는 것. */
|
||||
export interface DragHandleDeps {
|
||||
stepM: () => number | null;
|
||||
/** 끈 거리(m · 화면 왼쪽 + · 실제 아래 +) → 맞춘 자리. null = 지금 고른 것은 끌 수 없다. */
|
||||
snap: (leftM: number, downM: number) => DragSnap | null;
|
||||
/** 끄는 동안 창에 적을 미리 보기(빈 글이면 지운다). */
|
||||
preview: (text: string) => void;
|
||||
/** 놓을 때 한 번 — 화면 왼쪽(◀) 걸음 수 · 아래(▼) 걸음 수. 둘 중 하나만 0 이 아니다. */
|
||||
commit: (stepsLeft: number, stepsDown: number) => void;
|
||||
}
|
||||
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
const clampTo = (value: number, low: number, high: number): number =>
|
||||
Math.min(Math.max(value, low), high);
|
||||
|
||||
/** 십자 두 축 끌기를 맞추는 데 필요한 것 — 걸음 · ▲▼ 축 뜻 · 한계 · 반영. */
|
||||
export interface AxisDragSpec {
|
||||
stepM: number;
|
||||
/** ▲▼ 한 칸이 1:1.2 사면(바깥 d · 아래 d/1.2)이면 사면 바깥의 화면 방향(왼쪽 = 1) · 순수 수직이면 null. */
|
||||
slopeOutward: number | null;
|
||||
/** 걸음 맞춘 축 이동(◀ + · ▼ +) → 한계 안 가장 가까운 축 이동. */
|
||||
clamp: (left: number, down: number) => [number, number];
|
||||
apply: (left: number, down: number) => void;
|
||||
}
|
||||
|
||||
/** 1:1.2 사면 — 사면 축 한 칸 = 바깥 d · 아래 d/1.2(`_Culvert_Solve.placePipeWall` · 집수정과 같음). */
|
||||
const SLOPE_RUN = FILL_SLOPE_RATIO_MIN;
|
||||
|
||||
/**
|
||||
* 끈 거리를 십자 두 축으로 나눠 걸음에 맞춘다(108-2) — ◀▶ 축 + ▲▼ 축을 함께.
|
||||
* 사면 축이면 아래 성분이 사면 칸을 정하고, 남은 가로가 ◀▶ 칸이 된다. 한계에 걸리면
|
||||
* `clamp` 가 가장 가까운 허용 자리로 자르고, 미리 보기도 그 자리(놓은 뒤 결과와 같음)다.
|
||||
*/
|
||||
export function snapAxisDrag(leftM: number, downM: number, spec: AxisDragSpec): DragSnap {
|
||||
const { stepM, slopeOutward: outward } = spec;
|
||||
const snapStep = (value: number): number => round1(Math.round(value / stepM) * stepM);
|
||||
const wantDown = snapStep(outward === null ? downM : downM * SLOPE_RUN);
|
||||
const wantLeft = snapStep(leftM - (outward ?? 0) * wantDown);
|
||||
const [left, down] = spec.clamp(wantLeft, wantDown).map(round1);
|
||||
return {
|
||||
leftM: left + (outward ?? 0) * down,
|
||||
downM: outward === null ? down : down / SLOPE_RUN,
|
||||
text: moveText(left, down),
|
||||
apply: () => spec.apply(left, down),
|
||||
};
|
||||
}
|
||||
|
||||
/** 미리 보기 글 — 십자 단추 뜻 그대로(◀▶ m · ▲▼ m). */
|
||||
function moveText(left: number, down: number): string {
|
||||
return [
|
||||
left ? `${left > 0 ? "◀" : "▶"} ${Math.abs(left).toFixed(1)}m` : "",
|
||||
down ? `${down > 0 ? "▼" : "▲"} ${Math.abs(down).toFixed(1)}m` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
/** 축 한계 — 지금 값 `from` 에서 `move` 만큼 가되 [low, high] 안(조정값 공간). */
|
||||
export function clampAxis(from: number, move: number, low: number, high: number): number {
|
||||
return round1(clampTo(from + move, low, high) - from);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그림 위 손잡이(계획서 24-4a) — 고른 구조물 도형을 누른 채 끌면 십자와 같은 걸음으로 옮긴다.
|
||||
* 끄는 동안은 도형만 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가 다시 그려진다.
|
||||
* 좌우 · 상하 중 많이 끈 쪽 하나만 쓴다(십자와 같음). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
|
||||
* 끄는 동안은 도형만 맞춘 자리로 옮겨 보이고(미리 보기) 놓을 때 한 번 반영한다 — 반영하면 카드가
|
||||
* 다시 그려진다. 좌우 · 상하 두 축을 함께 쓴다(108-2). 축척은 카드 도면이 적어 둔 `data-px-per-m`.
|
||||
*/
|
||||
export function bindDragHandle(
|
||||
root: HTMLElement,
|
||||
@@ -126,33 +186,22 @@ export function bindDragHandle(
|
||||
event.clientY <= box.bottom + 3
|
||||
);
|
||||
});
|
||||
const stepM = deps.stepM();
|
||||
const pxPerM = Number(svg.dataset.pxPerM);
|
||||
const exaggeration = Number(svg.dataset.exaggeration) || 1;
|
||||
const ctm = layer.getScreenCTM();
|
||||
if (!onShape || !stepM || !(pxPerM > 0) || !ctm) return;
|
||||
if (!onShape || !deps.snap(0, 0) || !(pxPerM > 0) || !ctm) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const [startX, startY] = [event.clientX, event.clientY];
|
||||
let left = 0;
|
||||
let down = 0;
|
||||
let snapped: DragSnap | null = null;
|
||||
const move = (next: PointerEvent): void => {
|
||||
const dx = next.clientX - startX;
|
||||
const dy = next.clientY - startY;
|
||||
const horizontal = Math.abs(dx) >= Math.abs(dy);
|
||||
left = horizontal ? Math.round(-dx / (pxPerM * ctm.a) / stepM) : 0;
|
||||
down = horizontal ? 0 : Math.round(dy / (pxPerM * exaggeration * ctm.d) / stepM);
|
||||
const tx = -left * stepM * pxPerM;
|
||||
const ty = down * stepM * pxPerM * exaggeration;
|
||||
const leftM = -(next.clientX - startX) / (pxPerM * ctm.a);
|
||||
const downM = (next.clientY - startY) / (pxPerM * exaggeration * ctm.d);
|
||||
snapped = deps.snap(leftM, downM);
|
||||
const tx = -(snapped?.leftM ?? 0) * pxPerM;
|
||||
const ty = (snapped?.downM ?? 0) * pxPerM * exaggeration;
|
||||
shapes.forEach((shape) => shape.setAttribute("transform", `translate(${tx} ${ty})`));
|
||||
const meters = (Math.abs(left || down) * stepM).toFixed(1);
|
||||
deps.preview(
|
||||
left
|
||||
? `${left > 0 ? "◀" : "▶"} ${meters}m`
|
||||
: down
|
||||
? `${down > 0 ? "▼" : "▲"} ${meters}m`
|
||||
: "",
|
||||
);
|
||||
deps.preview(snapped?.text ?? "");
|
||||
};
|
||||
const up = (): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
@@ -160,12 +209,12 @@ export function bindDragHandle(
|
||||
window.removeEventListener("pointercancel", up);
|
||||
shapes.forEach((shape) => shape.removeAttribute("transform"));
|
||||
deps.preview("");
|
||||
if (!left && !down) return;
|
||||
if (!snapped?.text) return;
|
||||
// 놓은 자리의 click 이 「같은 구조물 다시 누름 = 선택 해제」로 번지지 않게 한 번 막는다.
|
||||
const swallow = (click: Event): void => click.stopPropagation();
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
|
||||
deps.commit(left, down);
|
||||
snapped.apply();
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
dragReadout,
|
||||
makeButton,
|
||||
makeRow,
|
||||
snapAxisDrag,
|
||||
type AxisDragSpec,
|
||||
} from "./B06_Section_UI_Cross_Panel_Base";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
@@ -66,6 +68,8 @@ export interface StructurePanelDeps {
|
||||
canNudge: (key: RevetKey) => boolean;
|
||||
/** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */
|
||||
fineMove: (key: RevetKey) => boolean;
|
||||
/** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */
|
||||
dragAxes?: (key: RevetKey, basin: boolean) => Omit<AxisDragSpec, "stepM"> | null;
|
||||
/** 다단 기슭막이 상태 — 유출 벽 선택 시 단 수 행 표시 판단에 쓴다. */
|
||||
extraState: () => { canAdd: boolean; count: number };
|
||||
/** 이 카드가 연동으로 넘어온 링크 카드인가 — 단 수는 **기준(소유) 측점**에서만
|
||||
@@ -407,14 +411,13 @@ export function buildStructurePanel(
|
||||
return { button, set };
|
||||
};
|
||||
const LINK_TITLE =
|
||||
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
|
||||
"옆 측점 흙막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
|
||||
/** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */
|
||||
const LINK_LOCKED_TITLE =
|
||||
"경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다.";
|
||||
const LINK_LOCKED_TITLE = "경사 해제(수평) 중에는 연동을 풀 수 없다 — 흙막이 Z를 하나로 묶는다.";
|
||||
const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on));
|
||||
const gradeToggle = makeToggle(
|
||||
"경사",
|
||||
"종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
|
||||
"종단경사 반영(3D 전용) — 켜면 3D 흙막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
|
||||
(on) => deps.setFollowGrade(on),
|
||||
);
|
||||
|
||||
@@ -457,9 +460,8 @@ export function buildStructurePanel(
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "b06-structure-panel__head";
|
||||
const basinReset = makeButton("↺", "집수정 기본값", () => deps.resetBasin());
|
||||
basinReset.classList.add("b06-structure-panel__basin-reset");
|
||||
head.append(title, basinReset, closeButton);
|
||||
// 집수정 기본값은 위치 십자 가운데 ↺ 하나(108-1 — 머리줄은 제목 · 닫기만).
|
||||
head.append(title, closeButton);
|
||||
// 오버레이 = 읽기 + 십자(기슭막이) · 위치 십자(집수정)만 — 값 칸이 없다(24-4a). 두 조작
|
||||
// 세트는 **창 맨 아래**(2026-08-24 사용자) · 동시에 뜨지 않아 같은 자리에 온다.
|
||||
// 사본 = 폼 칸으로 옮겨 붙을 줄만(`_Adjust_Dock`) — 십자는 오버레이 전용(2026-08-29 지시 7).
|
||||
@@ -478,22 +480,16 @@ export function buildStructurePanel(
|
||||
: [basinRow.row, moveRow.row]),
|
||||
);
|
||||
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 같은 동작(24-4a). 오버레이만.
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 두 축 함께 · 놓을 때 한 번(108-2). 오버레이만.
|
||||
let dragKind: "wall" | "basin" | null = null;
|
||||
const drag = bindDragHandle(root, {
|
||||
stepM: () =>
|
||||
!current || !dragKind ? null : dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current),
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
commit: (left, down) => {
|
||||
if (!current) return;
|
||||
if (dragKind === "basin") {
|
||||
if (left) deps.nudgeBasinLateral(left * BASIN_STEP_M);
|
||||
if (down) moveBasinSlope(down);
|
||||
return;
|
||||
}
|
||||
if (left) deps.nudge(current, left * moveStepOf(current));
|
||||
if (down) deps.nudgeSlope(current, down * moveStepOf(current));
|
||||
snap: (leftM, downM) => {
|
||||
const axes = current && dragKind ? deps.dragAxes?.(current, dragKind === "basin") : null;
|
||||
if (!current || !axes) return null;
|
||||
const stepM = dragKind === "basin" ? BASIN_STEP_M : moveStepOf(current);
|
||||
return snapAxisDrag(leftM, downM, { stepM, ...axes });
|
||||
},
|
||||
preview: (text) => (dragLine.textContent = text),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -541,7 +537,6 @@ export function buildStructurePanel(
|
||||
.querySelector(".b06-structure-panel__btn--equal")
|
||||
?.classList.toggle("is-hidden", isOwn || key !== "outlet");
|
||||
basinRow.row.classList.toggle("is-hidden", !isBasin);
|
||||
basinReset.classList.toggle("is-hidden", !isBasin);
|
||||
// I형은 내공(높이·폭) 개념이 없다 — 내공 줄을 숨긴다.
|
||||
for (const entry of basinSizeRows) {
|
||||
// 화면에서 숨김(54-40 · 값 · 그림 계산은 그대로)
|
||||
|
||||
@@ -213,6 +213,7 @@ export function createCrossSectionCard(
|
||||
box,
|
||||
isCardSelected: () => isSelected,
|
||||
clearArea: () => {
|
||||
if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1)
|
||||
if (activeArea === null) return;
|
||||
activeArea = null;
|
||||
setBandActive(null);
|
||||
@@ -235,10 +236,10 @@ export function createCrossSectionCard(
|
||||
setChipActive(null);
|
||||
}
|
||||
if (activeRevet !== null) {
|
||||
// 미선택 카드는 **카드 선택만** 부른다. 여기서 면적 비우기(onAreaSelect)를 먼저
|
||||
// 부르면 그쪽이 카드를 선택해 버려, 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로
|
||||
// 먹혀 아무 일도 일어나지 않는다(2026-08-21 화면 확인). 카드 선택 경로가 부모의
|
||||
// 면적 강조를 이미 비운다.
|
||||
bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
|
||||
// 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이
|
||||
// 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21
|
||||
// 화면 확인). 카드 선택 경로가 부모의 면적 강조를 이미 비운다.
|
||||
if (wasSelected) onAreaSelect?.(section.station_id, null);
|
||||
else onSelect(section.station_id);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps {
|
||||
}),
|
||||
nudgeRise: (role, deltaM) =>
|
||||
box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }),
|
||||
move: (role, screenDeltaM, riseDeltaM) => {
|
||||
const from = box.adjustFor(chainageM)[role];
|
||||
box.update(chainageM, role, {
|
||||
lengthM: Math.round((from.lengthM + screenDeltaM * (role === "left" ? 1 : -1)) * 10) / 10,
|
||||
riseM: Math.round((from.riseM + riseDeltaM) * 10) / 10,
|
||||
});
|
||||
},
|
||||
reset: (role) => box.reset(chainageM, role),
|
||||
wingRoleFor: (role) => ((role === "left") === context.inletOnLeft ? "inlet" : "outlet"),
|
||||
bodyLengthM: () => context.layout()?.bodyLengthM ?? 0,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford";
|
||||
export interface FordPanelContext {
|
||||
section: CrossSection;
|
||||
ford: FordControl;
|
||||
/** 마지막 계산의 측벽 높이(m) — 높이 조작의 시작값. */
|
||||
/** 마지막 계산의 측벽 높이(m) — 읽기 줄. */
|
||||
heightFor: (role: FordWallRole) => number;
|
||||
/** 마지막 계산의 바닥판 길이(m). */
|
||||
slabLengthM: () => number;
|
||||
@@ -31,8 +31,6 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
|
||||
return {
|
||||
adjustFor: (role) => ford.adjustFor(chainage)[role],
|
||||
heightFor: context.heightFor,
|
||||
nudgeHeight: (role, deltaM) =>
|
||||
ford.update(chainage, role, { heightM: context.heightFor(role) + deltaM }),
|
||||
nudge: (role, screenDeltaM) =>
|
||||
ford.update(chainage, role, {
|
||||
lateralM: ford.adjustFor(chainage)[role].lateralM + screenDeltaM * outwardOf(section, role),
|
||||
@@ -41,6 +39,14 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
|
||||
ford.update(chainage, role, {
|
||||
slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM,
|
||||
}),
|
||||
outwardOf: (role) => outwardOf(section, role),
|
||||
move: (role, screenDeltaM, slopeDeltaM) => {
|
||||
const from = ford.adjustFor(chainage)[role];
|
||||
ford.update(chainage, role, {
|
||||
lateralM: from.lateralM + screenDeltaM * outwardOf(section, role),
|
||||
slopeM: from.slopeM + slopeDeltaM,
|
||||
});
|
||||
},
|
||||
reset: (role) => ford.reset(chainage, role),
|
||||
slabLengthM: context.slabLengthM,
|
||||
close: context.close,
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import {
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
PIPE_WALL_DEFAULT_RUN_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_FORM_DEFAULT,
|
||||
WALL_MIN_PURE_HEIGHT_M,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { clampAxis, type AxisDragSpec } from "./B06_Section_UI_Cross_Panel_Base";
|
||||
import type { CulvertLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
@@ -149,6 +152,7 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80).
|
||||
fineMove: (key) =>
|
||||
(key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()),
|
||||
dragAxes: (key, basin) => wallDragAxes(ctx, key, basin),
|
||||
extraState: () => ctx.extraState(),
|
||||
isLinkedCard: () => ctx.isLinked,
|
||||
basinExtraState: () => ctx.basinExtraState(),
|
||||
@@ -222,6 +226,70 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
};
|
||||
}
|
||||
|
||||
/** 끌기 한계 ±10 m — 제어기 `clampMove`(`_Page_Station_Controls`)와 같다. */
|
||||
const DRAG_LIMIT_M = 10;
|
||||
const round1 = (value: number): number => Math.round(value * 10) / 10;
|
||||
|
||||
/**
|
||||
* 벽 · 집수정 그림 끌기 두 축(108-2) — 축 뜻 · 한계 · 한 번 반영. 한계는 저장 · 기하가 거는 것 중
|
||||
* 미리 알 수 있는 것(±10 m · 안쪽 금지 · 노면 위 금지 · 다단 선반 1.2·d) — 넘으면 가장 가까운 허용 자리.
|
||||
* · 배관 기준벽 · 집수정 = ▲▼ 1:1.2 사면(`placePipeWall` · `_Culvert_Basin` 사면 이동)
|
||||
* · 다단 · 독립 벽 = ▲▼ 순수 수직(`_Culvert_Extra` · `_Revetment` 축)
|
||||
*/
|
||||
function wallDragAxes(
|
||||
ctx: StructurePanelContext,
|
||||
key: RevetKey,
|
||||
basin: boolean,
|
||||
): Omit<AxisDragSpec, "stepM"> | null {
|
||||
if (!ctx.revetOffset) return null;
|
||||
const outward = ctx.outwardOf(basin ? "inlet" : key);
|
||||
if (basin) {
|
||||
const from = ctx.inletStructure?.adjustFor(ctx.section);
|
||||
if (!from) return null;
|
||||
const lateralMove = (left: number): number =>
|
||||
clampAxis(from.lateralM, left * outward, 0, DRAG_LIMIT_M);
|
||||
return {
|
||||
slopeOutward: outward,
|
||||
clamp: (left, down) => [
|
||||
lateralMove(left) * outward,
|
||||
clampAxis(from.slopeM, down, 0, DRAG_LIMIT_M),
|
||||
],
|
||||
apply: (left, down) =>
|
||||
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
|
||||
...from,
|
||||
lateralM: round1(from.lateralM + left * outward),
|
||||
slopeM: round1(from.slopeM + down),
|
||||
}),
|
||||
};
|
||||
}
|
||||
const adjust = ctx.adjustOf(key);
|
||||
const d0 = adjust.d ?? ctx.appliedD().get(key) ?? 0;
|
||||
const diagonal = key === "inlet" || key === "outlet";
|
||||
// x 하한 — 배관 기준벽 = 안쪽 금지(흙막이는 본체 끝이 0점이라 안 막음 · 73-4) · 다단 = 선반 ≥ 0 ·
|
||||
// 독립 = 성토선 각도(1.2·d − 기본 run)
|
||||
const xLow = (d: number): number =>
|
||||
isTierKey(key)
|
||||
? Math.max(0, FILL_SLOPE_RATIO_MIN * d)
|
||||
: key === "own"
|
||||
? FILL_SLOPE_RATIO_MIN * d - PIPE_WALL_DEFAULT_RUN_M
|
||||
: ctx.bodyGuard?.()
|
||||
? -DRAG_LIMIT_M
|
||||
: 0;
|
||||
return {
|
||||
slopeOutward: diagonal ? outward : null,
|
||||
clamp: (left, down) => {
|
||||
const dMove = clampAxis(d0, down, 0, DRAG_LIMIT_M);
|
||||
const low = Math.max(xLow(d0 + dMove), -DRAG_LIMIT_M);
|
||||
return [clampAxis(adjust.x, left * outward, low, DRAG_LIMIT_M) * outward, dMove];
|
||||
},
|
||||
apply: (left, down) =>
|
||||
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
|
||||
x: round1(adjust.x + left * outward),
|
||||
d: round1(d0 + down),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 조정창이 쓸 **카드 상태**를 마지막 기하 결과에서 뽑는다(2026-08-25 분리 — 카드
|
||||
* 렌더러 700줄). 값 계산은 하지 않고 레이아웃 결과를 조정창 키 체계로 옮기기만 한다.
|
||||
|
||||
@@ -109,6 +109,8 @@ export interface FordControlDeps {
|
||||
queuePipeOptions: (chainageM: number, patch: Record<string, number | string>) => void;
|
||||
round1: (value: number) => number;
|
||||
clampMove: (value: number) => number;
|
||||
/** 같은 측점 벽 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1). */
|
||||
releaseWall: (chainageM: number) => void;
|
||||
}
|
||||
|
||||
export interface FordControls {
|
||||
@@ -225,6 +227,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
|
||||
},
|
||||
selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null,
|
||||
select: (chainageM, role) => {
|
||||
if (role) deps.releaseWall(chainageM);
|
||||
fordSelections.set(chainageM.toFixed(2), role);
|
||||
},
|
||||
};
|
||||
@@ -425,6 +428,7 @@ export function createBoxControls(deps: FordControlDeps): {
|
||||
},
|
||||
selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null,
|
||||
select: (chainageM, role) => {
|
||||
if (role) deps.releaseWall(chainageM);
|
||||
selections.set(chainageM.toFixed(2), role);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -266,6 +266,10 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
},
|
||||
select: (chainageM, key) => {
|
||||
revetSelection = key ? { at: chainageM.toFixed(2), key } : null;
|
||||
// 벽을 고르면 같은 측점 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
|
||||
const { ford, box } = bodyControls;
|
||||
if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);
|
||||
if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);
|
||||
revetOffsetControl.onSelectionChange?.();
|
||||
},
|
||||
syncApplied: (chainageM, role, applied) => {
|
||||
@@ -561,6 +565,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
queuePipeOptions: culvertOptions.queue,
|
||||
round1,
|
||||
clampMove,
|
||||
releaseWall: (chainageM) => {
|
||||
if (revetSelection?.at !== chainageM.toFixed(2)) return;
|
||||
revetSelection = null;
|
||||
revetOffsetControl.onSelectionChange?.();
|
||||
},
|
||||
},
|
||||
deps.sessionKey,
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
HaulEquipmentLimit,
|
||||
SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
|
||||
import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
@@ -197,6 +198,26 @@ export function createSectionView(
|
||||
};
|
||||
if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights;
|
||||
|
||||
/**
|
||||
* 고른 구간형 구조물(노면공 등)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1).
|
||||
* 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다.
|
||||
*/
|
||||
let spanMarks: ReadonlyArray<StructureInstance> = [];
|
||||
let spanMarkId: string | null = null;
|
||||
const inSpan = (section: CrossSection): boolean => {
|
||||
const mark = spanMarkId ? spanMarks.find((entry) => entry.structure_id === spanMarkId) : null;
|
||||
const { start_m: start, end_m: end } = mark ?? {};
|
||||
if (start == null || end == null || start === end) return false;
|
||||
const at = section.chainage_m;
|
||||
return at >= Math.min(start, end) - 0.01 && at <= Math.max(start, end) + 0.01;
|
||||
};
|
||||
const syncSpanHighlights = (): void => {
|
||||
for (const section of currentDetail?.cross_sections ?? [])
|
||||
document
|
||||
.getElementById(`cross-${section.station_id}`)
|
||||
?.classList.toggle("b06-cross-card--span", inSpan(section));
|
||||
};
|
||||
|
||||
/** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */
|
||||
let stationSelectListener: ((stationId: string | null) => void) | null = null;
|
||||
|
||||
@@ -269,8 +290,8 @@ export function createSectionView(
|
||||
section: CrossSection,
|
||||
forcedHeightPx?: number,
|
||||
plotBase?: CrossPlotBase | null,
|
||||
): HTMLElement =>
|
||||
createCrossSectionCard(
|
||||
): HTMLElement => {
|
||||
const card = createCrossSectionCard(
|
||||
section,
|
||||
section.station_id === selectedStationId,
|
||||
currentExaggeration,
|
||||
@@ -296,6 +317,9 @@ export function createSectionView(
|
||||
plotBase,
|
||||
cutSlope,
|
||||
);
|
||||
card.classList.toggle("b06-cross-card--span", inSpan(section));
|
||||
return card;
|
||||
};
|
||||
|
||||
/**
|
||||
* 옆 측점 구조물이 이 측점까지 넘어왔는지 — 기슭막이 기본 연장 10m(전 5·후 5)면
|
||||
@@ -483,7 +507,11 @@ export function createSectionView(
|
||||
);
|
||||
}
|
||||
},
|
||||
selectMark: (structureId) => profile.selectMark(structureId),
|
||||
selectMark: (structureId) => {
|
||||
spanMarkId = structureId;
|
||||
syncSpanHighlights();
|
||||
profile.selectMark(structureId);
|
||||
},
|
||||
setStationSelectListener(listener) {
|
||||
stationSelectListener = listener;
|
||||
},
|
||||
@@ -491,6 +519,8 @@ export function createSectionView(
|
||||
structureEdit = edit;
|
||||
},
|
||||
setStructureMarks(structures, types) {
|
||||
spanMarks = structures;
|
||||
syncSpanHighlights();
|
||||
profile.setMarks(structures, types);
|
||||
// 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다
|
||||
// (`__corridorBuild` 등과 같은 용도).
|
||||
|
||||
@@ -175,6 +175,12 @@
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent);
|
||||
}
|
||||
|
||||
/* 구간형 구조물(노면공 등)을 고르면 시작~종료 안 모든 카드(106-1) — 가까운 한 장의 선택 강조보다 옅게. */
|
||||
.b06-cross-card--span:not(.b06-cross-card--selected) {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 45%, transparent);
|
||||
}
|
||||
|
||||
/* 제목행 1행 구조(E-2/E-3): 측점 라벨 → 단면유형 pill → 지반유형 → 구조물·kind. */
|
||||
.b06-cross-card > header {
|
||||
display: flex;
|
||||
|
||||
@@ -174,8 +174,9 @@
|
||||
grid-area: se;
|
||||
}
|
||||
|
||||
.b06-structure-panel__basin-reset {
|
||||
margin-left: auto;
|
||||
/* 횡단도 안 조정창 = 제목 · 단추만(108-1) — 줄 이름은 좌측 사본에만. */
|
||||
.b06-structure-panel:not(.b06-structure-panel--dock) .b06-structure-panel__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b06-structure-panel__select {
|
||||
@@ -297,11 +298,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 집수정 기본값(↺)은 9키와 한 세트라 오버레이에만 둔다. */
|
||||
.b06-structure-panel--dock .b06-structure-panel__basin-reset {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 좌측 폼과 중복된 행은 사본에서 숨긴다(2026-08-29 사용자 지시 3·5):
|
||||
* 형태 = 유입구·유출구 그룹의 "형태", 구조물 형식 = 유입구 "구조"로 합쳤다. */
|
||||
.b06-structure-panel--dock .b06-structure-panel__struct--form,
|
||||
|
||||
@@ -180,7 +180,10 @@ export interface PlanKind {
|
||||
id: string;
|
||||
name: string;
|
||||
pieces: string[];
|
||||
/** 양식 — plan(평면도 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각 없음). */
|
||||
layout: PlanKindLayout;
|
||||
}
|
||||
export type PlanKindLayout = "plan" | "watershed";
|
||||
export interface PlanMark {
|
||||
chainage_m: number;
|
||||
refuge: boolean;
|
||||
@@ -202,10 +205,11 @@ export function addPlanKind(
|
||||
projectId: string,
|
||||
name: string,
|
||||
pieces: string[],
|
||||
layout: PlanKindLayout = "plan",
|
||||
): Promise<PlanKindsState> {
|
||||
return requestJson(`/projects/${projectId}/plan-kinds`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, pieces }),
|
||||
body: JSON.stringify({ name, pieces, layout }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -110,33 +110,31 @@ def ip_blocks(
|
||||
|
||||
|
||||
def _place(blocks: list[dict[str, Any]], width_mm: float) -> None:
|
||||
"""묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 아래부터 나란히 · 넘치면 위로 다음 줄."""
|
||||
"""묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 → 오른쪽 · 위 → 아래(첫 줄 맨 위).
|
||||
|
||||
묶음 전체는 도곽 왼쪽 아래 기준 — 줄이 늘면 위로 쌓이고 마지막 줄이 맨 아래다.
|
||||
"""
|
||||
block_w = sum(_COLUMNS)
|
||||
per_line = max(1, int((width_mm - 2 * _MARGIN) // block_w))
|
||||
lines = -(-len(blocks) // per_line)
|
||||
for order, block in enumerate(blocks):
|
||||
line, column = divmod(order, per_line)
|
||||
top = _MARGIN + line * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H
|
||||
top = _MARGIN + (lines - 1 - line) * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H
|
||||
block["origin"] = [_MARGIN + column * block_w, top]
|
||||
block["rows"] = _END_ROWS if "ia" not in block else _IP_ROWS
|
||||
|
||||
|
||||
def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None:
|
||||
"""표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다."""
|
||||
min_x, min_y = box[0], box[1]
|
||||
width = sum(_COLUMNS) / MM
|
||||
area = unary_union(
|
||||
[
|
||||
rect(
|
||||
min_x + b["origin"][0] / MM,
|
||||
min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM,
|
||||
min_x + b["origin"][0] / MM + width,
|
||||
min_y + b["origin"][1] / MM,
|
||||
)
|
||||
for b in blocks
|
||||
]
|
||||
)
|
||||
def clip_lines(
|
||||
pieces: dict[str, dict[str, Any]],
|
||||
rects: list[tuple[float, float, float, float]],
|
||||
names: tuple[str, ...],
|
||||
) -> None:
|
||||
"""상자들(사업지 m · 최소 x · y · 최대 x · y) 밑 선 조각 `names` 를 상자 밖으로 잘라 낸다."""
|
||||
if not rects:
|
||||
return
|
||||
area = unary_union([rect(*bounds) for bounds in rects])
|
||||
inside = prep(area)
|
||||
for piece in _CLIPPED:
|
||||
for piece in names:
|
||||
lines = (pieces.get(piece) or {}).get("lines")
|
||||
if not lines:
|
||||
continue
|
||||
@@ -155,6 +153,22 @@ def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box:
|
||||
pieces[piece]["lines"] = kept
|
||||
|
||||
|
||||
def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None:
|
||||
"""표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다."""
|
||||
min_x, min_y = box[0], box[1]
|
||||
width = sum(_COLUMNS) / MM
|
||||
rects = [
|
||||
(
|
||||
min_x + b["origin"][0] / MM,
|
||||
min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM,
|
||||
min_x + b["origin"][0] / MM + width,
|
||||
min_y + b["origin"][1] / MM,
|
||||
)
|
||||
for b in blocks
|
||||
]
|
||||
clip_lines(pieces, rects, _CLIPPED)
|
||||
|
||||
|
||||
def ip_table_piece(
|
||||
pieces: dict[str, dict[str, Any]],
|
||||
curves: list[dict[str, Any]],
|
||||
|
||||
@@ -75,7 +75,7 @@ LEGACY_PIECES: dict[str, tuple[str, ...]] = {
|
||||
_OUTLINE = LEGACY_PIECES["slope_outline"]
|
||||
|
||||
_TERRAIN = ("contour", "stream", "centerline", *_OUTLINE, "slope_ticks", "ip", "radius")
|
||||
# 기본 종류 넷 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다.
|
||||
# 기본 종류 — 지울 수 있다. id 는 옛 도면 id(plan_terrain …)와 같아 확정본이 이어진다.
|
||||
DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
|
||||
{"id": "terrain", "name": "지형", "pieces": [*_TERRAIN, "station"]},
|
||||
{
|
||||
@@ -100,6 +100,8 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = (
|
||||
"name": "용지도",
|
||||
"pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"],
|
||||
},
|
||||
# 배수유역도 = 양식이 다른 종류(1/6,000 한 장 · 정보표) — 조각을 고르지 않는다(109장).
|
||||
{"id": "watershed", "name": "배수유역도", "pieces": [], "layout": "watershed"},
|
||||
)
|
||||
|
||||
_COLORS = {
|
||||
@@ -127,7 +129,7 @@ _BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = {
|
||||
"emd": (2, [8, 4]),
|
||||
"sgg": (3, [14, 5, 3, 5]),
|
||||
}
|
||||
_PARCEL_FONT = 1.8
|
||||
PARCEL_FONT = 3.6 # 필지 정보 글 크기(종이 mm) — 용지도 자리 잡기도 이 값
|
||||
TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다
|
||||
_MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수)
|
||||
_LABEL_FONT = 2.5
|
||||
@@ -518,7 +520,7 @@ def _piece_entities(
|
||||
"rect",
|
||||
layer=layer,
|
||||
color=color,
|
||||
font=_PARCEL_FONT,
|
||||
font=PARCEL_FONT,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -39,14 +39,12 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
|
||||
CROSS_STANDARD_ID,
|
||||
MASS_HAUL_ID,
|
||||
WATERSHED_ID,
|
||||
_cross_sheet_plan,
|
||||
_drawing_list,
|
||||
_invalidate_drawing,
|
||||
_read_drawing,
|
||||
_read_json,
|
||||
_store_confirmed_drawing,
|
||||
watershed_source,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source
|
||||
@@ -322,12 +320,6 @@ async def get_design_drawing(
|
||||
async with pool.acquire() as connection:
|
||||
section = await get_longitudinal_section(connection, project_id, route_id)
|
||||
source_design = ((section or {}).get("data") or {}).get("mass_haul")
|
||||
elif drawing_id == WATERSHED_ID:
|
||||
# 유역도는 B04 배수유역 산출물 + 도엽 배경을 사업지 CRS로 모아 넘긴다.
|
||||
context, reason = await load_drainage_context(project_id)
|
||||
if context is None:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": reason})
|
||||
source_design = await asyncio.to_thread(watershed_source, context)
|
||||
elif drawing_id == CROSS_STANDARD_ID:
|
||||
# 표준 횡단면도는 B06 「표준 횡단면 설정」 저장값으로 그린다(없으면 config 기본값).
|
||||
pool = get_db_pool()
|
||||
|
||||
@@ -34,6 +34,8 @@ router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||
class PlanKindRequest(BaseModel):
|
||||
name: str
|
||||
pieces: list[str] = Field(default_factory=list)
|
||||
# 양식 — plan(평면도 · 조각 고름) · watershed(유역도). 추가에서만 쓰고 업데이트는 그대로 둔다.
|
||||
layout: str = "plan"
|
||||
|
||||
|
||||
class PlanMark(BaseModel):
|
||||
@@ -79,10 +81,10 @@ async def get_plan_kinds(project_id: UUID) -> Any:
|
||||
|
||||
@router.post("/{project_id}/plan-kinds")
|
||||
async def post_plan_kind(project_id: UUID, request: PlanKindRequest) -> Any:
|
||||
"""종류 추가 — 조각을 골라 이름을 붙인다."""
|
||||
"""종류 추가 — 양식 · 조각을 골라 이름을 붙인다."""
|
||||
try:
|
||||
root = await _project_root(project_id)
|
||||
await asyncio.to_thread(add_kind, root, request.name, request.pieces)
|
||||
await asyncio.to_thread(add_kind, root, request.name, request.pieces, request.layout)
|
||||
return await asyncio.to_thread(_state, root)
|
||||
except ValueError as exc:
|
||||
return _error(400, exc)
|
||||
|
||||
@@ -76,6 +76,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
PLAN_KIND_ID,
|
||||
WATERSHED_LAYOUT,
|
||||
plan_kind_items,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Schema import (
|
||||
@@ -92,7 +93,6 @@ _CROSS_ID = re.compile(rf"^cross_{CROSS_KEY_PATTERN}m$")
|
||||
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
|
||||
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
|
||||
MASS_HAUL_ID = "mass_haul"
|
||||
WATERSHED_ID = "watershed"
|
||||
COVER_ID = "cover"
|
||||
# 표준 횡단면도 — 노선 자료가 아니라 B06 표준 횡단면 설정값으로 그리는 한 장.
|
||||
CROSS_STANDARD_ID = "cross_standard"
|
||||
@@ -156,7 +156,6 @@ def _drawing_list(
|
||||
(COVER_ID, "cover", "표지"),
|
||||
(CROSS_STANDARD_ID, "cross_standard", STANDARD_LABEL),
|
||||
(MASS_HAUL_ID, "mass_haul", "토적도(유토곡선)"),
|
||||
(WATERSHED_ID, "watershed", "유역도(배수 유역도)"),
|
||||
):
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
@@ -164,12 +163,6 @@ def _drawing_list(
|
||||
kind=kind,
|
||||
label=label,
|
||||
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
|
||||
# 배수유역도는 평면도 묶음 안 항목 — 축척(1/6,000 한 장)이 달라 종류는 아니다.
|
||||
**(
|
||||
{"plan_kind": WATERSHED_ID, "plan_kind_name": "배수유역도"}
|
||||
if drawing_id == WATERSHED_ID
|
||||
else {}
|
||||
),
|
||||
)
|
||||
)
|
||||
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④.
|
||||
@@ -405,7 +398,7 @@ def _read_drawing(
|
||||
# 포맷 버전이 다르면(테이블·레이어 구성 변경 전 저장본) 캐시를 버리고
|
||||
# 아래에서 원본 기준으로 재생성한다. 확정 상태도 무효로 응답해 재확정 유도.
|
||||
if saved.get("format") == DRAWING_FORMAT:
|
||||
if drawing_id in (COVER_ID, MASS_HAUL_ID, WATERSHED_ID, CROSS_STANDARD_ID):
|
||||
if drawing_id in (COVER_ID, MASS_HAUL_ID, CROSS_STANDARD_ID):
|
||||
kind = drawing_id # id와 kind가 같은 단장 도면
|
||||
elif PLAN_KIND_ID.fullmatch(drawing_id):
|
||||
kind = "plan"
|
||||
@@ -451,36 +444,22 @@ def _read_drawing(
|
||||
if not isinstance(stored_design, dict):
|
||||
raise FileNotFoundError("계획평면도 자료가 없습니다.")
|
||||
label = str(stored_design.get("label") or drawing_id)
|
||||
return (
|
||||
"plan",
|
||||
label,
|
||||
build_plan_kind_drawing(
|
||||
drawing_id, label, stored_design["box"], stored_design.get("pieces") or {}
|
||||
),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
if drawing_id == WATERSHED_ID:
|
||||
# stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
|
||||
if not isinstance(stored_design, dict):
|
||||
raise FileNotFoundError("배수 유역 산출물이 없습니다.")
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
return (
|
||||
"watershed",
|
||||
"유역도(배수 유역도)",
|
||||
build_watershed_drawing(
|
||||
if stored_design.get("layout") == WATERSHED_LAYOUT:
|
||||
# 유역도 양식 — watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS).
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
drawing = build_watershed_drawing(
|
||||
drawing_id,
|
||||
stored_design.get("route_xy") or [],
|
||||
stored_design.get("basins") or [],
|
||||
stored_design.get("contours") or [],
|
||||
stored_design.get("streams") or [],
|
||||
interval,
|
||||
),
|
||||
False,
|
||||
None,
|
||||
)
|
||||
infer_station_interval(longitudinal.get("stations") or []),
|
||||
)
|
||||
else:
|
||||
drawing = build_plan_kind_drawing(
|
||||
drawing_id, label, stored_design["box"], stored_design.get("pieces") or {}
|
||||
)
|
||||
return "plan", label, drawing, False, None
|
||||
|
||||
if _LONG_ID.fullmatch(drawing_id):
|
||||
source = _read_json(longitudinal_path)
|
||||
|
||||
@@ -20,7 +20,6 @@ _CROSS_ID = re.compile(r"^cross_(\d+)m$")
|
||||
_LONG_ID = re.compile(r"^longitudinal(?:_(\d+))?$")
|
||||
# 노선 전장에 한 장씩만 나오는 도면 — 라우터가 원본 자료를 따로 실어 넘긴다.
|
||||
MASS_HAUL_ID = "mass_haul"
|
||||
WATERSHED_ID = "watershed"
|
||||
COVER_ID = "cover"
|
||||
|
||||
# ⚠ 빈-도각 목록이 여기 있었다 — 계획평면도·표준 횡단면도·용지도·표준도 일곱 줄.
|
||||
|
||||
@@ -19,16 +19,18 @@ from shapely.validation import make_valid
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import PARCEL_FONT
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_rings, _geojson_features
|
||||
|
||||
LANDUSE_PIECES = ("parcel", "parcel_info", "emd", "sgg")
|
||||
# 필지 정보 상자 밑에서 잘라 낼 선 조각 — 배경이 글을 가리지 않게(109-4).
|
||||
LABEL_CLIPPED = ("contour", "stream", "parcel", "emd", "sgg")
|
||||
|
||||
# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84).
|
||||
PARCEL_FILE = "연속지적도_bounds.geojson"
|
||||
EMD_FILE = "행정구역_읍면동_bounds.geojson"
|
||||
SGG_FILE = "행정구역_시군구_bounds.geojson"
|
||||
|
||||
PARCEL_FONT = 1.8 # 필지 정보 글 크기(종이 mm)
|
||||
ROUTE_CLEAR_M = 5.0 # 라벨이 비켜 갈 노선 버퍼(중심선에서 m)
|
||||
_GAP_M = 2.0 # 지시선 끝과 상자 사이 틈(m)
|
||||
_RINGS = 5 # 후보 자리 고리 수
|
||||
@@ -187,6 +189,17 @@ def place_labels(
|
||||
return items
|
||||
|
||||
|
||||
def label_rects(items: list[dict[str, Any]]) -> list[tuple[float, float, float, float]]:
|
||||
"""필지 정보 상자 자리(사업지 m) — 그리는 상자(`_balloon_entities`)와 같은 크기."""
|
||||
rects = []
|
||||
for item in items:
|
||||
width_mm, height_mm = balloon_size(item["lines"], PARCEL_FONT)
|
||||
half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0
|
||||
cx, cy = item["center"]
|
||||
rects.append((cx - half_w, cy - half_h, cx + half_w, cy + half_h))
|
||||
return rects
|
||||
|
||||
|
||||
def landuse_pieces(
|
||||
project_root: Path,
|
||||
crs: str,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""B07 계획평면도 종류 · 정보 조각 파일 (PLAN 60장).
|
||||
|
||||
저장 자리(프로젝트 저장소 `B07_DesignDetail/`):
|
||||
plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order} — M02 양식 층처럼 파일 하나씩
|
||||
plan_kinds/{종류 id}.json 종류 하나 = {name, pieces, order, layout} — 파일 하나씩
|
||||
plan_pieces/{장}/{조각}.json 정보 조각 하나 = 그 장 범위의 기하(사업지 좌표 m)
|
||||
plan_marks.json 사용자가 정한 대피소 · 사토장 자리
|
||||
|
||||
`plan_kinds/` 가 없으면 기본 넷(지형 · 노선배치도 · 대피소 · 라이다)을 쓴다. 처음 고칠 때
|
||||
기본 넷을 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 넷도 고치고 지울 수 있다.
|
||||
`plan_kinds/` 가 없으면 기본 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도)를
|
||||
쓴다. 양식(layout) = plan(1/1,200 장 나눔 · 조각 고름) · watershed(유역도 1/6,000 한 장 · 조각
|
||||
없음). 처음 고칠 때 기본 종류를 파일로 내려 두고 그 위에서 추가 · 업데이트 · 지운다 — 기본 종류도
|
||||
고치고 지울 수 있다.
|
||||
조각은 서버가 도면을 열 때마다 원자료(B05 노선 · 곡선표 · 구조물, B06 횡단 설계, B04 배경)
|
||||
에서 다시 계산해 파일로 쓰고, 도면은 그 파일을 읽어 고른 조각만 얹는다.
|
||||
"""
|
||||
@@ -24,7 +26,7 @@ from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_piece
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines, ip_table_piece
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import (
|
||||
_structure_label,
|
||||
plan_area_mm,
|
||||
@@ -47,6 +49,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import (
|
||||
_sheet_box,
|
||||
map_background,
|
||||
plan_stations,
|
||||
watershed_source,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_design_root,
|
||||
@@ -55,7 +58,9 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_write_manifest,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
|
||||
LABEL_CLIPPED,
|
||||
LANDUSE_PIECES,
|
||||
label_rects,
|
||||
landuse_pieces,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
@@ -65,6 +70,8 @@ from config.config_system import DRAWING_SCALE_PLAN
|
||||
KIND_ID = re.compile(r"^[a-z][a-z0-9]{0,31}$")
|
||||
PLAN_KIND_ID = re.compile(r"^plan_([a-z][a-z0-9]{0,31})(?:_(\d+))?$")
|
||||
_NAME_MAX = 40
|
||||
WATERSHED_LAYOUT = "watershed"
|
||||
_LAYOUTS = ("plan", WATERSHED_LAYOUT)
|
||||
_REFUGE_TYPES = {"refuge": "대피소", "spoil_bank": "사토장"}
|
||||
_REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄우는 거리(좌측)
|
||||
|
||||
@@ -80,16 +87,24 @@ def _kinds_dir(project_root: Path) -> Path:
|
||||
return _design_root(project_root) / "plan_kinds"
|
||||
|
||||
|
||||
# 이관 표시 — 있으면 용지도 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음).
|
||||
_LANDUSE_MARK = "_landuse_added"
|
||||
# 이관 표시 `_{id}_added` — 있으면 그 기본 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음).
|
||||
_ADDED_ONCE = ("landuse", "watershed")
|
||||
|
||||
|
||||
def _add_landuse_once(folder: Path) -> None:
|
||||
"""93장 앞에 종류 파일을 내린 프로젝트 — 기본 종류 「용지도」를 한 번 더한다."""
|
||||
mark = folder / _LANDUSE_MARK
|
||||
if mark.exists():
|
||||
def _document(kind: dict[str, Any], order: int) -> dict[str, Any]:
|
||||
return {
|
||||
"name": kind["name"],
|
||||
"pieces": list(kind["pieces"]),
|
||||
"order": order,
|
||||
"layout": kind.get("layout", "plan"),
|
||||
}
|
||||
|
||||
|
||||
def _add_defaults_once(folder: Path) -> None:
|
||||
"""기본 종류가 늘기 전에 종류 파일을 내린 프로젝트 — 늘어난 기본 종류를 한 번 더한다."""
|
||||
pending = [kind_id for kind_id in _ADDED_ONCE if not (folder / f"_{kind_id}_added").exists()]
|
||||
if not pending:
|
||||
return
|
||||
landuse = next(kind for kind in DEFAULT_KINDS if kind["id"] == "landuse")
|
||||
documents = []
|
||||
for path in folder.glob("*.json"):
|
||||
try:
|
||||
@@ -97,21 +112,23 @@ def _add_landuse_once(folder: Path) -> None:
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
names = {str(document.get("name")) for document in documents}
|
||||
if not (folder / "landuse.json").exists() and landuse["name"] not in names:
|
||||
order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1
|
||||
atomic_write_json(
|
||||
folder / "landuse.json",
|
||||
{"name": landuse["name"], "pieces": list(landuse["pieces"]), "order": order},
|
||||
)
|
||||
mark.touch()
|
||||
order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1
|
||||
for kind_id in pending:
|
||||
kind = next(kind for kind in DEFAULT_KINDS if kind["id"] == kind_id)
|
||||
if not (folder / f"{kind_id}.json").exists() and kind["name"] not in names:
|
||||
atomic_write_json(folder / f"{kind_id}.json", _document(kind, order))
|
||||
order += 1
|
||||
(folder / f"_{kind_id}_added").touch()
|
||||
|
||||
|
||||
def list_kinds(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 종류."""
|
||||
"""종류 목록 [{id, name, pieces, layout}] — 차례대로. 파일이 없으면 기본 종류."""
|
||||
folder = _kinds_dir(project_root)
|
||||
if not folder.is_dir():
|
||||
return [{**kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS]
|
||||
_add_landuse_once(folder)
|
||||
return [
|
||||
{"layout": "plan", **kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS
|
||||
]
|
||||
_add_defaults_once(folder)
|
||||
rows: list[tuple[int, str, dict[str, Any]]] = []
|
||||
for path in folder.glob("*.json"):
|
||||
if not KIND_ID.fullmatch(path.stem):
|
||||
@@ -123,67 +140,76 @@ def list_kinds(project_root: Path) -> list[dict[str, Any]]:
|
||||
stored = set()
|
||||
for piece in document.get("pieces") or []:
|
||||
stored.update(LEGACY_PIECES.get(piece, (piece,)))
|
||||
pieces = [piece for piece in PIECE_IDS if piece in stored]
|
||||
layout = document.get("layout") if document.get("layout") in _LAYOUTS else "plan"
|
||||
pieces = [] if layout == WATERSHED_LAYOUT else [p for p in PIECE_IDS if p in stored]
|
||||
rows.append(
|
||||
(
|
||||
int(document.get("order") or 0),
|
||||
path.stem,
|
||||
{"id": path.stem, "name": str(document.get("name") or path.stem), "pieces": pieces},
|
||||
{
|
||||
"id": path.stem,
|
||||
"name": str(document.get("name") or path.stem),
|
||||
"pieces": pieces,
|
||||
"layout": layout,
|
||||
},
|
||||
)
|
||||
)
|
||||
return [row for _order, _id, row in sorted(rows, key=lambda item: item[:2])]
|
||||
|
||||
|
||||
def _materialize(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""기본 넷을 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번."""
|
||||
"""기본 종류를 파일로 내려 둔다(이미 있으면 그대로) — 고치기 전에 한 번."""
|
||||
kinds = list_kinds(project_root)
|
||||
folder = _kinds_dir(project_root)
|
||||
if not folder.is_dir():
|
||||
for order, kind in enumerate(kinds):
|
||||
atomic_write_json(
|
||||
folder / f"{kind['id']}.json",
|
||||
{"name": kind["name"], "pieces": kind["pieces"], "order": order},
|
||||
)
|
||||
(folder / _LANDUSE_MARK).touch()
|
||||
atomic_write_json(folder / f"{kind['id']}.json", _document(kind, order))
|
||||
for kind_id in _ADDED_ONCE:
|
||||
(folder / f"_{kind_id}_added").touch()
|
||||
return kinds
|
||||
|
||||
|
||||
def _checked(name: str, pieces: list[str]) -> tuple[str, list[str]]:
|
||||
def _checked(name: str, pieces: list[str], layout: str = "plan") -> tuple[str, list[str]]:
|
||||
text = str(name or "").strip()
|
||||
if not text or len(text) > _NAME_MAX:
|
||||
raise ValueError(f"종류 이름은 1~{_NAME_MAX}자로 적어 주세요.")
|
||||
if layout not in _LAYOUTS:
|
||||
raise ValueError("올바르지 않은 양식입니다.")
|
||||
if layout == WATERSHED_LAYOUT:
|
||||
return text, [] # 유역도 양식은 그림이 정해져 있다 — 조각을 고르지 않음
|
||||
chosen = [piece for piece in PIECE_IDS if piece in set(pieces or [])]
|
||||
if not chosen:
|
||||
raise ValueError("얹을 정보를 하나 이상 고르세요.")
|
||||
return text, chosen
|
||||
|
||||
|
||||
def add_kind(project_root: Path, name: str, pieces: list[str]) -> dict[str, Any]:
|
||||
text, chosen = _checked(name, pieces)
|
||||
def add_kind(
|
||||
project_root: Path, name: str, pieces: list[str], layout: str = "plan"
|
||||
) -> dict[str, Any]:
|
||||
text, chosen = _checked(name, pieces, layout)
|
||||
kinds = _materialize(project_root)
|
||||
if any(kind["name"] == text for kind in kinds):
|
||||
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
|
||||
kind_id = f"k{uuid.uuid4().hex[:8]}"
|
||||
atomic_write_json(
|
||||
_kinds_dir(project_root) / f"{kind_id}.json",
|
||||
{"name": text, "pieces": chosen, "order": len(kinds)},
|
||||
)
|
||||
return {"id": kind_id, "name": text, "pieces": chosen}
|
||||
kind = {"id": f"k{uuid.uuid4().hex[:8]}", "name": text, "pieces": chosen, "layout": layout}
|
||||
atomic_write_json(_kinds_dir(project_root) / f"{kind['id']}.json", _document(kind, len(kinds)))
|
||||
return kind
|
||||
|
||||
|
||||
def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str]) -> dict[str, Any]:
|
||||
"""종류 이름 · 조각을 고친다(차례는 그대로). 그 종류의 확정본은 풀어 새 조각으로 다시 그린다."""
|
||||
"""종류 이름 · 조각을 고친다(차례 · 양식은 그대로). 그 종류의 확정본은 풀어 다시 그린다."""
|
||||
if not KIND_ID.fullmatch(kind_id):
|
||||
raise ValueError("올바르지 않은 종류 id 입니다.")
|
||||
text, chosen = _checked(name, pieces)
|
||||
kinds = _materialize(project_root)
|
||||
current = next((kind for kind in kinds if kind["id"] == kind_id), None)
|
||||
path = _kinds_dir(project_root) / f"{kind_id}.json"
|
||||
if current is None or not path.is_file():
|
||||
raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.")
|
||||
text, chosen = _checked(name, pieces, current["layout"])
|
||||
if any(kind["name"] == text and kind["id"] != kind_id for kind in kinds):
|
||||
raise ValueError(f"같은 이름의 종류가 이미 있습니다: {text}")
|
||||
path = _kinds_dir(project_root) / f"{kind_id}.json"
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError("고칠 종류를 찾을 수 없습니다.")
|
||||
order = int(_read_json(path).get("order") or 0)
|
||||
atomic_write_json(path, {"name": text, "pieces": chosen, "order": order})
|
||||
kind = {"id": kind_id, "name": text, "pieces": chosen, "layout": current["layout"]}
|
||||
atomic_write_json(path, _document(kind, order))
|
||||
manifest = _read_manifest(project_root)
|
||||
released = False
|
||||
for drawing_id, entry in manifest["drawings"].items():
|
||||
@@ -193,7 +219,7 @@ def update_kind(project_root: Path, kind_id: str, name: str, pieces: list[str])
|
||||
released = True
|
||||
if released:
|
||||
_write_manifest(project_root, manifest)
|
||||
return {"id": kind_id, "name": text, "pieces": chosen}
|
||||
return kind
|
||||
|
||||
|
||||
def delete_kind(project_root: Path, kind_id: str) -> None:
|
||||
@@ -449,6 +475,8 @@ def build_pieces(
|
||||
pieces.update(
|
||||
landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route])
|
||||
)
|
||||
if "parcel_info" in wanted:
|
||||
clip_lines(pieces, label_rects(pieces["parcel_info"]["items"]), LABEL_CLIPPED)
|
||||
if "ip_table" in wanted:
|
||||
pieces["ip_table"] = ip_table_piece(
|
||||
pieces, _load_design_curves(project_root), route, box, chunk, total
|
||||
@@ -477,6 +505,9 @@ def plan_kind_source(
|
||||
)
|
||||
if kind is None:
|
||||
raise FileNotFoundError("계획평면도 종류를 찾을 수 없습니다.")
|
||||
if kind["layout"] == WATERSHED_LAYOUT:
|
||||
# 유역도 양식 — B04 배수유역 산출물 + 도엽 배경(사업지 CRS) 한 장.
|
||||
return {"layout": WATERSHED_LAYOUT, "label": kind["name"], **watershed_source(context)}
|
||||
number = int(match.group(2)) if match.group(2) else 1
|
||||
pieces, box, chunk, total = build_pieces(
|
||||
context, longitudinal, longitudinal_path, designs, number, kind["pieces"]
|
||||
@@ -494,6 +525,16 @@ def plan_kind_items(project_root: Path, longitudinal: dict[str, Any]) -> list[di
|
||||
sheets = plan_chunks(plan_stations(longitudinal))
|
||||
items = []
|
||||
for kind in list_kinds(project_root):
|
||||
if kind["layout"] == WATERSHED_LAYOUT: # 1/6,000 한 장 — 장 나눔 없음
|
||||
items.append(
|
||||
{
|
||||
"id": f"plan_{kind['id']}",
|
||||
"label": kind["name"],
|
||||
"plan_kind": kind["id"],
|
||||
"plan_kind_name": kind["name"],
|
||||
}
|
||||
)
|
||||
continue
|
||||
title = plan_title(kind["name"])
|
||||
for chunk in sheets:
|
||||
single = len(sheets) <= 1
|
||||
|
||||
@@ -29,7 +29,7 @@ export const DRAWING_GROUPS: readonly {
|
||||
idPrefix?: string;
|
||||
}[] = [
|
||||
{ label: "표지", kind: "cover" },
|
||||
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 사용자 추가) + 배수유역도를 한 묶음으로.
|
||||
// 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 배수유역도 · 사용자 추가)를 한 묶음으로.
|
||||
{ label: "평면도", kind: "plan" },
|
||||
{ label: "종단도", kind: "longitudinal" },
|
||||
// 표준 횡단면도는 횡단도 묶음 맨 위 한 줄(93-5).
|
||||
@@ -89,7 +89,7 @@ export function buildDrawingSidePanel(
|
||||
for (const group of DRAWING_GROUPS) {
|
||||
if (group.kind === "plan") {
|
||||
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
|
||||
const plans = drawings.filter((item) => item.kind === "plan" || item.kind === "watershed");
|
||||
const plans = drawings.filter((item) => item.kind === "plan");
|
||||
panel.append(buildPlanSection(plans, drawingButton, plan));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
* 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장).
|
||||
*
|
||||
* 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다.
|
||||
* 체크박스는 공용(`ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸.
|
||||
* 드롭다운 · 체크박스는 공용(`createSelectField` · `ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createSelectField, showToast } from "@ui/ui_template_elements";
|
||||
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
import {
|
||||
addPlanKind,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
savePlanMarks,
|
||||
updatePlanKind,
|
||||
type DesignDrawingItem,
|
||||
type PlanKindLayout,
|
||||
type PlanKindsState,
|
||||
type PlanMark,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
@@ -51,15 +52,31 @@ async function run(action: () => Promise<unknown>, controls: PlanKindsControls):
|
||||
}
|
||||
}
|
||||
|
||||
/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */
|
||||
/** 양식 — 평면도(조각 고름 · 1/1,200 장 나눔) · 유역도(1/6,000 한 장 · 그림 고정). */
|
||||
const LAYOUTS: { value: PlanKindLayout; text: string }[] = [
|
||||
{ value: "plan", text: "평면도" },
|
||||
{ value: "watershed", text: "유역도" },
|
||||
];
|
||||
|
||||
/** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 양식 고르고 [추가]. */
|
||||
function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement {
|
||||
const form = el("div", "b07-plan-tools__block");
|
||||
form.append(el("strong", "", "종류"));
|
||||
const pick = el("select", "b07-plan-tools__kind");
|
||||
pick.append(
|
||||
...state.kinds.map((kind) => new Option(kind.name, kind.id)),
|
||||
new Option("새 종류", ""),
|
||||
);
|
||||
const pick = createSelectField({
|
||||
options: [
|
||||
...state.kinds.map((kind) => ({ value: kind.id, text: kind.name })),
|
||||
{ value: "", text: "새 종류" },
|
||||
],
|
||||
compact: true,
|
||||
ariaLabel: "종류",
|
||||
onChange: () => show(),
|
||||
});
|
||||
const layout = createSelectField({
|
||||
options: LAYOUTS,
|
||||
compact: true,
|
||||
ariaLabel: "양식",
|
||||
onChange: () => showPieces(),
|
||||
});
|
||||
const name = el("input", "b07-plan-tools__name");
|
||||
name.type = "text";
|
||||
name.placeholder = "종류 이름";
|
||||
@@ -69,27 +86,35 @@ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLEleme
|
||||
pieceList.append(...boxes.map((box) => box.root));
|
||||
const action = el("button", "b07-plan-tools__button");
|
||||
action.type = "button";
|
||||
// 유역도 양식은 그림이 정해져 있어 얹을 정보를 고르지 않는다.
|
||||
const showPieces = (): void => {
|
||||
pieceList.hidden = layout.select.value === "watershed";
|
||||
};
|
||||
const show = (): void => {
|
||||
const kind = state.kinds.find((item) => item.id === pick.value);
|
||||
const kind = state.kinds.find((item) => item.id === pick.select.value);
|
||||
name.value = kind?.name ?? "";
|
||||
boxes.forEach(({ input }) => (input.checked = kind?.pieces.includes(input.value) ?? false));
|
||||
// 양식은 새로 만들 때만 고른다 — 있는 종류는 그 양식을 보이기만.
|
||||
layout.select.value = kind?.layout ?? "plan";
|
||||
layout.select.disabled = Boolean(kind);
|
||||
action.textContent = kind ? "업데이트" : "추가";
|
||||
showPieces();
|
||||
};
|
||||
pick.addEventListener("change", show);
|
||||
show();
|
||||
action.addEventListener("click", () => {
|
||||
const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value);
|
||||
const kindId = pick.value;
|
||||
const kindId = pick.select.value;
|
||||
const label = name.value.trim();
|
||||
const chosen = layout.select.value as PlanKindLayout;
|
||||
void run(
|
||||
() =>
|
||||
kindId
|
||||
? updatePlanKind(controls.projectId, kindId, label, pieces)
|
||||
: addPlanKind(controls.projectId, label, pieces),
|
||||
: addPlanKind(controls.projectId, label, pieces, chosen),
|
||||
controls,
|
||||
);
|
||||
});
|
||||
form.append(pick, name, pieceList, action);
|
||||
form.append(pick.root, layout.root, name, pieceList, action);
|
||||
return form;
|
||||
}
|
||||
|
||||
@@ -173,8 +198,7 @@ export function buildPlanSection(
|
||||
makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name),
|
||||
);
|
||||
head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]);
|
||||
// × 는 종류만 — 배수유역도는 묶음 안 고정 항목(지우면 서버가 404).
|
||||
if (controls && sheets[0].kind === "plan") {
|
||||
if (controls) {
|
||||
const remove = el("button", "b07-plan-tools__remove", "×");
|
||||
remove.type = "button";
|
||||
remove.title = `「${name}」 종류 지우기`;
|
||||
|
||||
@@ -279,7 +279,10 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
}
|
||||
|
||||
.b07-plan-tools__kind,
|
||||
.b07-plan-tools__pieces[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b07-plan-tools__name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 · B05 연결값 한 곳 (PLAN 62-3 · 73-11 · 73-12 · 96).
|
||||
|
||||
표준값(유출계수 · 할증 · 재현기간 · 최소 도달시간)은 B05 배수 설정(config DRAINAGE_*)과 같은 값.
|
||||
측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사 · 관 종류 · n 은 안 이음.
|
||||
측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사는 B06 횡단 경사(110) ·
|
||||
관 종류 · n 은 안 이음.
|
||||
근거 후보: docs/raw/조사/2026-10-06_62-1_홍수량_방법.md (별표2 2.0배 · KDS 표 2.7-1 · 실무 견본).
|
||||
"""
|
||||
|
||||
@@ -17,6 +18,7 @@ from config.config_system_design import (
|
||||
DRAINAGE_RUNOFF_COEFFICIENT,
|
||||
DRAINAGE_TC_MIN_MINUTES,
|
||||
)
|
||||
from M02_MasterTemplete.M02_Table_Fill import _nearest
|
||||
|
||||
#: 할증(Qd = Q × 할증) — 2.0 별표2 법정 · 1.5 견본(울진 2공구) · 1.2 구기준
|
||||
FACTORS = (2.0, 1.5, 1.2)
|
||||
@@ -66,6 +68,11 @@ SHEET_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
#: 장 → B06 횡단 정본 경사 키(110) — 관 = 관 바닥 두 끝 경사 · 박스 = 박스 두 끝 원지반 경사
|
||||
#: (본체는 늘 수평) · 세월교 · 물넘이는 장 기본값(B05 세월교 높이 계산과 같은 값)
|
||||
SLOPE_KEYS = {"pipe": "pipe_slope_pct", "box": "box_ground_slope_pct"}
|
||||
|
||||
|
||||
def _positive(value: Any) -> float | None:
|
||||
"""양수만 — 글 '1000' 도 숫자로 · 0 · 빈 값 · 글자는 None(빈칸)."""
|
||||
try:
|
||||
@@ -75,9 +82,29 @@ def _positive(value: Any) -> float | None:
|
||||
return number if number > 0 else None
|
||||
|
||||
|
||||
def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""B05 값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션."""
|
||||
def bed_slopes(designs: list[dict[str, Any]] | None) -> dict[str, dict[float, float]]:
|
||||
"""B06 횡단 설계 `[{chainage_m, design}]` → 장별 `{측점: 경사 %}` — 0 % 이하는 안 담음(빈칸)."""
|
||||
found: dict[str, dict[float, float]] = {sheet: {} for sheet in SLOPE_KEYS}
|
||||
for row in designs or []:
|
||||
design = row.get("design") or {}
|
||||
for sheet, key in SLOPE_KEYS.items():
|
||||
pct = _positive(design.get(key))
|
||||
if pct:
|
||||
found[sheet][round(float(row.get("chainage_m") or 0.0), 3)] = pct
|
||||
return found
|
||||
|
||||
|
||||
def _linked(
|
||||
sheet: str,
|
||||
options: dict[str, Any],
|
||||
basin: dict[str, Any] | None,
|
||||
slope_pct: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""연결값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션 ·
|
||||
하상경사 S = 1 / 분모 는 B06 경사(110 · 0 % 이하면 빈칸 · 손 입력)."""
|
||||
values: dict[str, Any] = {}
|
||||
if slope_pct:
|
||||
values["slope_denominator"] = round(100 / slope_pct, 3)
|
||||
if basin is not None:
|
||||
area_m2 = _positive(basin.get("area_m2"))
|
||||
values["area_km2"] = area_m2 / 1e6 if area_m2 else None
|
||||
@@ -94,13 +121,17 @@ def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) -
|
||||
|
||||
|
||||
def station_rows(
|
||||
points: list[Any], basins: list[dict[str, Any]] | None = None
|
||||
points: list[Any],
|
||||
basins: list[dict[str, Any]] | None = None,
|
||||
designs: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford).
|
||||
|
||||
basins(B05 세부유역 속성) 를 주면 측점마다 연결값 `linked` · 비교용 `b05`(Qd · T) — 측점 짝은
|
||||
`carry_indices`(±0.05 m) · 안 주면 둘 다 None.
|
||||
`carry_indices`(±0.05 m) · 안 주면 둘 다 None. designs(B06 횡단 설계) 경사는 관 지점과
|
||||
가장 가까운 횡단 측점(±0.5 m · 관 연장과 같은 짝)에서.
|
||||
"""
|
||||
slopes = bed_slopes(designs)
|
||||
points = sorted(points, key=lambda p: p.chainage_m)
|
||||
picks = (
|
||||
carry_indices([p.chainage_m for p in points], [float(b["chainage_m"]) for b in basins])
|
||||
@@ -114,7 +145,8 @@ def station_rows(
|
||||
row["linked"] = row["b05"] = None
|
||||
if basins is not None:
|
||||
basin = basins[pick] if pick is not None else None
|
||||
row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin)
|
||||
slope = _nearest(slopes.get(sheet, {}), p.chainage_m)
|
||||
row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin, slope)
|
||||
if basin is not None:
|
||||
row["b05"] = {
|
||||
"design_flow_m3s": basin.get("design_flow_m3s"),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6 · 73-11 · 73-12).
|
||||
|
||||
GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 연결값
|
||||
GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 · B06 연결값
|
||||
+ 기본값 · 선택지 + 공사지(지역 · R24) + 저장본
|
||||
POST /api/projects/{id}/quantity/hydro/calc 화면 입력 → 계산서 값(B08_Quantity_Engine_Hydro)
|
||||
PUT /api/projects/{id}/quantity/hydro [저장] — 측점별 입력을 B08 자기 파일에만 씀
|
||||
|
||||
측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) —
|
||||
화면이 빈칸만 채움 · 하상경사 · 관 종류 · n 은 잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음.
|
||||
측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) ·
|
||||
하상경사는 B06 횡단 설계(관 경사 · 박스 원지반 경사 · 110) — 화면이 빈칸만 채움 · 관 종류 · n 은
|
||||
잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,6 +20,11 @@ from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_confirmed_route_context,
|
||||
get_cross_section_designs,
|
||||
get_workflow_route_context,
|
||||
)
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B08_Quantity.B08_Quantity_Engine_Hydro import compute
|
||||
from B08_Quantity.B08_Quantity_Hydro_Defaults import FACTORS, PIPE_KINDS, options, station_rows
|
||||
@@ -81,7 +87,15 @@ async def _project_root(project_id: UUID) -> Path:
|
||||
@router.get("/{project_id}/quantity/hydro")
|
||||
async def hydro_sheet(project_id: UUID) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
stations = station_rows(read_pipe_points_file(pipe_points_path_in(root)), read_basins(root))
|
||||
# 설계 = B08 집계표와 같은 길(확정 노선 먼저) · 노선 전이면 경사 없이
|
||||
async with get_db_pool().acquire() as connection:
|
||||
context = await get_confirmed_route_context(connection, project_id)
|
||||
context = context or await get_workflow_route_context(connection, project_id)
|
||||
designs = (
|
||||
await get_cross_section_designs(connection, int(context["route_id"])) if context else []
|
||||
)
|
||||
points = read_pipe_points_file(pipe_points_path_in(root))
|
||||
stations = station_rows(points, read_basins(root), designs)
|
||||
project = await get_project_by_id(str(project_id)) or {}
|
||||
return {
|
||||
"stations": stations,
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* 장(sheet) 갈래 — 박스 = 사각 내공 B · H · 수심비(73-11) · 세월교 · 물넘이 = 월류 폭 → 월류 높이(73-12)
|
||||
* 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만
|
||||
* 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「-」.
|
||||
* B05 연결값과 같은 칸은 이름 옆 작은 「B05」(풍선 「B05 분석」) · Qd · T 옆에 B05 값 참고(96-2).
|
||||
* 연결값과 같은 칸은 이름 옆 작은 원천 표 — 「B05」(풍선 「B05 분석」) · 하상경사는 「B06」
|
||||
* (풍선 「B06 횡단 경사」 · 110) · Qd · T 옆에 B05 값 참고(96-2).
|
||||
*
|
||||
* 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게.
|
||||
* ========================================================================== */
|
||||
@@ -78,7 +79,7 @@ export interface HydroOptions {
|
||||
site: HydroSite;
|
||||
}
|
||||
|
||||
/** B05 배수유역 연결(96) — 칸 값 · 비교용 설계유량 · 도달시간(분) */
|
||||
/** 연결(96 · 110) — 칸 값(B05 배수유역 · 하상경사는 B06 횡단) · 비교용 설계유량 · 도달시간(분) */
|
||||
export interface HydroLink {
|
||||
values: Partial<Record<keyof HydroInputs, number>>;
|
||||
b05: { design_flow_m3s: number | null; tc_minutes: number | null } | null;
|
||||
@@ -236,7 +237,7 @@ export function buildHydroSheet(
|
||||
link: HydroLink = { values: {}, b05: null },
|
||||
): HydroSheet {
|
||||
const site = options.site;
|
||||
/** 연결된 칸의 「B05」 표 — 값이 연결값과 같을 때만 보임 */
|
||||
/** 연결된 칸의 원천 표(「B05」 · 「B06」) — 값이 연결값과 같을 때만 보임 */
|
||||
const marks = new Map<Key, HTMLElement>();
|
||||
const markLinked = (key: Key): void => {
|
||||
const mark = marks.get(key);
|
||||
@@ -263,9 +264,12 @@ export function buildHydroSheet(
|
||||
input.value = value == null ? "" : String(value);
|
||||
const linked = link.values[key];
|
||||
if (linked != null) {
|
||||
input.title = `B05 분석 ${linked}`;
|
||||
const mark = node("span", "b08-hydro__hint", "B05");
|
||||
mark.title = "B05 분석";
|
||||
// 원천 표 — 하상경사는 B06 횡단 경사(관 바닥 · 박스 원지반 두 끝 · 110) · 그 밖은 B05
|
||||
const [source, title] =
|
||||
key === "slope_denominator" ? ["B06", "B06 횡단 경사"] : ["B05", "B05 분석"];
|
||||
input.title = `${title} ${linked}`;
|
||||
const mark = node("span", "b08-hydro__hint", source);
|
||||
mark.title = title;
|
||||
mark.dataset.b08HydroLinked = key;
|
||||
marks.set(key, mark);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 본문 칸은 스크롤 칸이 아님(바깥 층이 스크롤) — 오른쪽 패널이 아래로 삐져나와 막대가 생겼다 사라지는 떨림 방지 */
|
||||
.m01-logic__main {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.m01-logic__calc {
|
||||
padding: var(--spacing-8);
|
||||
border-radius: var(--radius-cards);
|
||||
|
||||
@@ -109,11 +109,22 @@ def seed_values(labor: Any) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def seed_picks(labor: Any) -> dict[str, Any]:
|
||||
"""일위대가 문서 고름 → 프로젝트 고른자재 첫 값 `{로직: {호표 차례: MT키}}`(103-1)."""
|
||||
doc = labor.get("줄") if isinstance(labor, dict) else None
|
||||
return {
|
||||
str(line["로직"]): dict(line["고름"])
|
||||
for line in (doc or {}).get("담은로직") or []
|
||||
if isinstance(line, dict) and line.get("로직") and isinstance(line.get("고름"), dict)
|
||||
}
|
||||
|
||||
|
||||
def build_snapshot(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""원본 내용 + 프로젝트 값(입력값 첫 값 · 빈 고른자재) — `{파일 이름: 내용}` · SS 줄이 없으면
|
||||
LookupError."""
|
||||
"""원본 내용 + 프로젝트 값(입력값 · 고른자재 첫 값 = 일위대가 문서 것) — `{파일 이름: 내용}` ·
|
||||
SS 줄이 없으면 LookupError."""
|
||||
files = build_source(item)
|
||||
return {**files, VALUES_FILE: seed_values(files.get(LABOR_FILE)), PICKS_FILE: {}}
|
||||
labor = files.get(LABOR_FILE)
|
||||
return {**files, VALUES_FILE: seed_values(labor), PICKS_FILE: seed_picks(labor)}
|
||||
|
||||
|
||||
def source_files(files: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -150,7 +150,11 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])]
|
||||
logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직
|
||||
materials = [str(m.get("코드")) for m in ((labor or {}).get("줄") or {}).get("자재") or []]
|
||||
doc = (labor or {}).get("줄") or {}
|
||||
materials = [str(m.get("코드")) for m in doc.get("자재") or []]
|
||||
materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀
|
||||
str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values()
|
||||
]
|
||||
wanted = logic_keys + [code for code in materials if code]
|
||||
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
|
||||
return files
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
|
||||
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?}
|
||||
| {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬,
|
||||
담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?, 고름?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}],
|
||||
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
|
||||
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
|
||||
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
|
||||
@@ -14,11 +14,15 @@
|
||||
분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만.
|
||||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
로직 자재(103-1) = 담은 로직 맨 위 층 호표 재료 줄(`logic_mats` · 줄마다 `재료`) ·
|
||||
고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와
|
||||
같은 품목 두 번은 [저장] 때 400.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
@@ -34,6 +38,7 @@ COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
|
||||
MATERIAL_HEAD = "자재|"
|
||||
_MAT_COLS = ("코드", "이름", "규격", "단위")
|
||||
ALIAS_MAX = 40 # 별칭(44-6) 글자 수 상한 — 화면 칸과 같음
|
||||
_BRACE = re.compile(r"\{([^{}]+)\}") # 품목 조건의 {입력}
|
||||
|
||||
|
||||
def _bad(text: str) -> store.StoreError:
|
||||
@@ -109,6 +114,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
raise _bad(f"{where} · 입력값이 묶음 아님")
|
||||
memo, alias = one.get("메모"), _alias(one.get("별칭"), where)
|
||||
rate = _rate(one.get("켜쌓기율"), code, where)
|
||||
picks = _picks(one.get("고름"), logic, whole, where)
|
||||
lines.append(
|
||||
{
|
||||
"로직": code,
|
||||
@@ -117,6 +123,7 @@ def clean(doc: Any) -> dict[str, Any]:
|
||||
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
**({"켜쌓기율": rate} if rate is not None else {}),
|
||||
**({"고름": picks} if picks else {}),
|
||||
}
|
||||
)
|
||||
mats, codes = [], set()
|
||||
@@ -162,6 +169,28 @@ def _alias(alias: Any, where: str) -> str | None:
|
||||
return alias or None
|
||||
|
||||
|
||||
def _picks(value: Any, logic: dict, whole: Any, where: str) -> dict[str, str] | None:
|
||||
"""고름(103-1) = `{호표 차례: MT키}` — 맨 위 층 재료 줄 · M01 자재품목 키만 · 빈 것 = None."""
|
||||
if not value:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
raise _bad(f"{where} · 고름은 {{호표 차례: MT키}}")
|
||||
rows = logic.get("호표") or []
|
||||
out = {}
|
||||
for at, key in value.items():
|
||||
n = int(at) if str(at).isdigit() else -1
|
||||
if not 0 <= n < len(rows) or rows[n].get("종류") != "재료":
|
||||
raise _bad(f"{where} · 고름 {at}째는 재료 줄 아님")
|
||||
try:
|
||||
ok = str(key)[:2] == mm.MARKET and whole.get(str(key)) is not None
|
||||
except mf.FormulaError:
|
||||
ok = False
|
||||
if not ok:
|
||||
raise _bad(f"{where} · 고름 「{key}」 M01 자재품목에 없음")
|
||||
out[str(n)] = str(key)
|
||||
return out
|
||||
|
||||
|
||||
def _rate(value: Any, code: str, where: str) -> int | float | None:
|
||||
"""켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠)."""
|
||||
if value is None or value == "":
|
||||
@@ -372,7 +401,8 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"분모": pill.get("단위"),
|
||||
"배": pill.get("배") or 1,
|
||||
}
|
||||
lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))})
|
||||
got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름"))
|
||||
lines.append({**line, **got})
|
||||
mats = [
|
||||
{
|
||||
"코드": one["코드"],
|
||||
@@ -385,19 +415,58 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return settle(_json({"줄": lines, "자재": mats}), doc, have)
|
||||
|
||||
|
||||
def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]:
|
||||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` ·
|
||||
돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
|
||||
def run_line(
|
||||
whole: Any, code: str, given: Any, rate: Any = None, picks: Any = None
|
||||
) -> dict[str, Any]:
|
||||
"""로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율 · 고른 재료 = 엔진
|
||||
`choosing`) — `{기준, 줄, 재료, 까닭}` · 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음)."""
|
||||
try:
|
||||
logic = whole.logic(code)
|
||||
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
|
||||
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
|
||||
with mf.mp.choosing(picks):
|
||||
got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return {"기준": None, "까닭": str(e) or type(e).__name__}
|
||||
if "결과" in got:
|
||||
return {"결과": got["결과"], "기준": None, "까닭": None}
|
||||
# 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4)
|
||||
return {"기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None}
|
||||
money = {k: got[k] for k in (*COSTS, "계")}
|
||||
mats = logic_mats(whole, logic, got["줄"], picks)
|
||||
return {"기준": money, "줄": got["줄"], "재료": mats, "까닭": None}
|
||||
|
||||
|
||||
def logic_mats(whole: Any, logic: dict, lines: list[dict], picks: Any) -> list[dict[str, Any]]:
|
||||
"""로직 자재(103-1) — 맨 위 층 호표 재료 줄마다 엔진 답 줄(같은 차례) 그대로 · 금액은 로직
|
||||
재료비 안(합에 안 더함) · `걸림` = 품목 조건에 든 입력 이름(중간값이면 입력 전부 — 그 입력이
|
||||
바뀌면 화면이 고름을 풂 · 짝 `M02_Labor_LogicMats_Model.ts` `releaseTied`)."""
|
||||
if isinstance(logic.get("흐름"), dict):
|
||||
return [] # 흐름 로직 재료 자리는 입력 이름 — 이번엔 안 보임
|
||||
names = {str(spec.get("이름")) for spec in _specs(logic)}
|
||||
out = []
|
||||
for n, item in enumerate(logic.get("호표") or []):
|
||||
if item.get("종류") != "재료" or n >= len(lines):
|
||||
continue
|
||||
line, goods = lines[n], lines[n].get("품목")
|
||||
try:
|
||||
row = (whole.get(goods["키"]) if goods else None) or {}
|
||||
except mf.FormulaError:
|
||||
row = {}
|
||||
cond = item.get("요소")
|
||||
texts = cond.values() if isinstance(cond, dict) else [cond]
|
||||
tied = {w for t in texts if isinstance(t, str) for w in _BRACE.findall(t)}
|
||||
out.append(
|
||||
{
|
||||
"차례": n,
|
||||
"이름": item.get("이름") or "",
|
||||
"단위": item.get("단위") or "",
|
||||
**{k: line.get(k) for k in ("수량", "단가", "금액", "까닭")},
|
||||
"품목": goods,
|
||||
"가격": {slot: row.get(slot) for slot in mm.SLOTS},
|
||||
"고름": str(n) in (picks or {}),
|
||||
"걸림": sorted(tied if tied <= names else names) if tied else [],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def ref_line(ref: dict, got: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -421,6 +490,12 @@ def _json(value: Any) -> Any:
|
||||
|
||||
|
||||
def server(doc: Any) -> dict[str, Any]:
|
||||
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림)."""
|
||||
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림) ·
|
||||
사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈)."""
|
||||
got = clean(doc)
|
||||
return {**got, "계산값": calc(got)}
|
||||
out = calc(got)
|
||||
inside = {x["품목"]["키"] for one in out["줄"] for x in one.get("재료") or [] if x.get("품목")}
|
||||
twice = sorted({m["코드"] for m in got["자재"]} & inside)
|
||||
if twice:
|
||||
raise _bad(f"자재 「{', '.join(twice)}」 는 로직 자재에 있음 — 같은 품목 두 번 담음")
|
||||
return {**got, "계산값": out}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* =============================================================================
|
||||
* M02_Labor_LogicMats_Model.ts
|
||||
* 로직 자재(103-1) 자료 · 규칙(화면 없음) — 서버 calc `줄[].재료`(짝 `M02_Labor_Doc.logic_mats`) ·
|
||||
* 입력이 바뀌면 그 입력에 걸린 고름을 풂(`releaseTied`) · 사용자 자재와 같은 품목 막기(`insideKeys`).
|
||||
* 시험 `resources/tester/test_m02_labor_logic_mats.py`(Node 헬퍼).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 잡힌 품목 — 엔진 답 `품목`(값칸 = 단가가 나온 값 열) */
|
||||
export interface LogicMatItem {
|
||||
키: string;
|
||||
이름?: string | null;
|
||||
규격?: string | null;
|
||||
단위?: string | null;
|
||||
값칸?: string | null;
|
||||
}
|
||||
|
||||
/** 로직 자재 한 줄 — 맨 위 층 호표 재료 줄(금액은 로직 재료비 안 · 합에 안 더함) */
|
||||
export interface LogicMat {
|
||||
차례: number;
|
||||
이름: string;
|
||||
단위: string;
|
||||
수량: number | null;
|
||||
단가: number | null;
|
||||
금액: number | null;
|
||||
까닭?: string | null;
|
||||
품목: LogicMatItem | null;
|
||||
/** M01 자재품목 값 열 다섯(빈 값 null) */
|
||||
가격?: Record<string, number | null>;
|
||||
고름: boolean;
|
||||
/** 품목 조건에 든 입력 이름 — 그 입력이 바뀌면 고름을 풂 */
|
||||
걸림: string[];
|
||||
}
|
||||
|
||||
/** 입력값이 `before` 에서 바뀐 이름에 걸린 고름을 지움(다 풀리면 `고름` 칸째) — `row` 를 고쳐 돌려줌 */
|
||||
export function releaseTied<
|
||||
T extends { 입력값: Record<string, unknown>; 고름?: Record<string, string> },
|
||||
>(row: T, before: Record<string, unknown>, mats: readonly LogicMat[]): T {
|
||||
if (!row.고름) return row;
|
||||
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(row.입력값)]);
|
||||
const changed = [...keys].filter((k) => text(before[k]) !== text(row.입력값[k]));
|
||||
for (const mat of mats)
|
||||
if (mat.걸림.some((n) => changed.includes(n))) delete row.고름[String(mat.차례)];
|
||||
if (!Object.keys(row.고름).length) delete row.고름;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** calc 답의 로직 자재 품목 키 전부 — 사용자 자재 추가 막기 */
|
||||
export function insideKeys(lines: readonly { 재료?: LogicMat[] }[] | undefined): Set<string> {
|
||||
return new Set(
|
||||
(lines ?? []).flatMap((l) => (l.재료 ?? []).flatMap((m) => (m.품목 ? [m.품목.키] : []))),
|
||||
);
|
||||
}
|
||||
@@ -370,7 +370,9 @@ class Expand:
|
||||
|
||||
line = (doc.get("담은로직") or [{}])[0]
|
||||
code = str(line.get("로직") or "")
|
||||
got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율"))
|
||||
got = labor.run_line(
|
||||
self.whole, code, line.get("입력값"), line.get("켜쌓기율"), line.get("고름")
|
||||
)
|
||||
times = labor._number(doc.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
why = got.get("까닭") or (None if base else "돈 아닌 로직")
|
||||
@@ -397,7 +399,9 @@ class Expand:
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
pill = pill or {}
|
||||
got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"))
|
||||
got = labor.run_line(
|
||||
self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
|
||||
)
|
||||
times = labor._number(pill.get("배")) or Decimal(1)
|
||||
base = got.get("기준")
|
||||
return {
|
||||
|
||||
@@ -189,6 +189,9 @@ export interface StructureNumbers {
|
||||
/** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */
|
||||
일위대가합계?: number;
|
||||
합계기준?: boolean;
|
||||
/** 합계 칸 색(101-1) — 붉음 `error` > 노랑 `short` > 초록 `ok` · 까닭 글은 칸 툴팁 */
|
||||
합계상태?: "ok" | "short" | "error";
|
||||
합계까닭?: string;
|
||||
}
|
||||
|
||||
/** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
|
||||
|
||||
@@ -53,6 +53,16 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 로직 자재 줄(103-1) — 금액은 로직 단가 안(합에 안 더함 · 흐리게) · 바꾼 줄 = 소속 글 강조 */
|
||||
.m02-labrow__logic-mat .m02-labrow__money {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-labrow__logic-mat.is-picked-mat .m02-labrow__key {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-labrow__money,
|
||||
.m02-labrow__num {
|
||||
text-align: right;
|
||||
@@ -90,10 +100,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 순서(85-3) — 손잡이 + ▲▼ 작은 단추 · 끌어 놓을 줄 위 선 */
|
||||
/* 순서(85-3 · 102-1) — ▲ · ▼ 작은 단추 위아래 · 끌어 놓을 줄 위(위로) · 아래(아래로) 선 */
|
||||
.m02-labrow__order {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@@ -116,14 +126,37 @@
|
||||
box-shadow: inset 0 2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/* 순서 칸 2행(51-14) — 위 손잡이 ▲▼ / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */
|
||||
/* 줄 통째 끌기(102-2) — 끄는 줄 무리 흐리게 · 아래로 놓을 자리 = 그 무리 맨 아래 선 */
|
||||
.m02-labrow tr.is-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.m02-labrow tr.is-dragunder td {
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
/* 손잡이(102-1) — 차례 칸 앞 */
|
||||
.m02-labrow__no {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 단추 칸(51-14 · 102-1) — [▲ · ▼ 위아래][옵션|수정][삭제] · 옵션 · 삭제는 두 단추 높이(2행 병합 꼴) */
|
||||
.m02-labrow__tools {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--btn-group-gap);
|
||||
}
|
||||
|
||||
.m02-labrow__tools > .ui-btn-group {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 구분 칸 품셈 책(102-3) — 단가산출 줄만 · 아래 작은 글 */
|
||||
.m02-labrow__book {
|
||||
display: block;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */
|
||||
.m02-labrow .m02-labrow__optrow td {
|
||||
padding-top: 0;
|
||||
@@ -396,6 +429,20 @@
|
||||
grid-column: 1 / -1;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 끌기 선(102-2) — 칸마다 말고 줄 카드 하나에 */
|
||||
.m02-labrow tr.is-dragover td,
|
||||
.m02-labrow tr.is-dragunder td {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.m02-labrow tr.is-dragover {
|
||||
box-shadow: inset 0 2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.m02-labrow tr.is-dragunder {
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
|
||||
|
||||
@@ -131,6 +131,8 @@ export interface LaborRow {
|
||||
수량: LaborQty;
|
||||
/** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */
|
||||
켜쌓기율?: number | null;
|
||||
/** 고른 로직 자재(103-1) — {호표 차례: MT키} · 없으면 엔진 기본 */
|
||||
고름?: Record<string, string>;
|
||||
}
|
||||
export interface LaborMat {
|
||||
코드: string;
|
||||
@@ -167,6 +169,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
수량: r.수량 ?? null,
|
||||
...(r.참조 ? { 참조: r.참조 } : {}),
|
||||
...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}),
|
||||
...(r.고름 && Object.keys(r.고름).length ? { 고름: { ...r.고름 } } : {}),
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */
|
||||
|
||||
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
|
||||
|
||||
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
|
||||
@@ -36,6 +37,8 @@ export interface CalcLine {
|
||||
결과?: unknown;
|
||||
/** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */
|
||||
줄?: LogicLine[];
|
||||
/** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */
|
||||
재료?: LogicMat[];
|
||||
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ ·
|
||||
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */
|
||||
이름?: string | null;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* [확인] = 화면 캐시만 · 저장은 [저장]
|
||||
* · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈
|
||||
* · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨
|
||||
* · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) +
|
||||
* 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음
|
||||
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
|
||||
* · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
|
||||
* 정보 칸 구분 · 상세구분(51-12 · 승격 = 읽기만 + 연결 · 사전조합 = 고름) ·
|
||||
@@ -57,9 +59,12 @@ import {
|
||||
import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
|
||||
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
|
||||
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
|
||||
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
|
||||
import { keepKnown } from "./M02_Labor_Values_Model";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
|
||||
@@ -103,6 +108,8 @@ export function mountLaborDetail(
|
||||
const promote = lib === "promote";
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let calc: LaborCalc | null = null;
|
||||
/** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */
|
||||
let last: LaborCalc | null = null;
|
||||
let fail: string | null = null;
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
@@ -180,29 +187,43 @@ export function mountLaborDetail(
|
||||
};
|
||||
dgBox.addEventListener("toggle", drawDg);
|
||||
|
||||
// 줄 끄는 동안(102-2)엔 다시 그리지 않음 — 끝난 뒤 한 번
|
||||
const drawLogic = (): void =>
|
||||
logicHost.replaceChildren(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
||||
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
||||
edit && !promote
|
||||
? {
|
||||
open: pick,
|
||||
option: openRow,
|
||||
ref: openRef,
|
||||
qty: openQty,
|
||||
move: reorder,
|
||||
remove: removeRow,
|
||||
alias: setAlias,
|
||||
}
|
||||
: { open: pick, option: openRow, ref: openRef },
|
||||
afterRowDrag(logicHost, () =>
|
||||
logicHost.replaceChildren(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked },
|
||||
// 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠)
|
||||
edit && !promote
|
||||
? {
|
||||
open: pick,
|
||||
option: openRow,
|
||||
ref: openRef,
|
||||
qty: openQty,
|
||||
move: reorder,
|
||||
remove: removeRow,
|
||||
alias: setAlias,
|
||||
}
|
||||
: { open: pick, option: openRow, ref: openRef },
|
||||
),
|
||||
),
|
||||
);
|
||||
const draw = (): void => {
|
||||
drawLogic();
|
||||
matHost.replaceChildren(
|
||||
matTable(
|
||||
{ mats: cur.자재, calc, fail, cells: cells() },
|
||||
{
|
||||
mats: cur.자재,
|
||||
calc,
|
||||
fail,
|
||||
cells: cells(),
|
||||
lead: logicMatRows(
|
||||
cur.담은로직,
|
||||
calc ?? last,
|
||||
!calc && !fail,
|
||||
edit ? { swap: swapMat, reset: resetMat } : undefined,
|
||||
),
|
||||
},
|
||||
edit ? { open: openMat, remove: removeMat } : { open: openMat },
|
||||
),
|
||||
);
|
||||
@@ -222,7 +243,7 @@ export function mountLaborDetail(
|
||||
// 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음)
|
||||
const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined);
|
||||
if (!alive || mine !== seq) return;
|
||||
calc = got;
|
||||
calc = last = got;
|
||||
pills = new Map(
|
||||
(got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]),
|
||||
);
|
||||
@@ -343,6 +364,25 @@ export function mountLaborDetail(
|
||||
cur.자재.splice(i, 1);
|
||||
changed();
|
||||
}
|
||||
/** 로직 자재 [바꾸기](103-1) — 사용자 자재와 같은 품목은 막음 */
|
||||
function swapMat(i: number, at: number, key: string): void {
|
||||
const own = cur.자재.find((m) => m.코드 === key);
|
||||
if (own)
|
||||
return showToast(
|
||||
tc("Labor_AlreadyMat").replace("{value}", `${key} ${own.이름 ?? ""}`),
|
||||
"error",
|
||||
);
|
||||
const row = cur.담은로직[i];
|
||||
row.고름 = { ...row.고름, [String(at)]: key };
|
||||
changed();
|
||||
}
|
||||
function resetMat(i: number, at: number): void {
|
||||
const row = cur.담은로직[i];
|
||||
if (!row.고름) return;
|
||||
delete row.고름[String(at)];
|
||||
if (!Object.keys(row.고름).length) delete row.고름;
|
||||
changed();
|
||||
}
|
||||
|
||||
function openRow(i: number): void {
|
||||
const row: LaborRow = cur.담은로직[i];
|
||||
@@ -359,7 +399,9 @@ export function mountLaborDetail(
|
||||
const same =
|
||||
JSON.stringify(values) === JSON.stringify(row.입력값) &&
|
||||
next.켜쌓기율 === (row.켜쌓기율 ?? null);
|
||||
const before = row.입력값;
|
||||
row.입력값 = values;
|
||||
releaseTied(row, before, lineOf(row.로직)?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1)
|
||||
row.메모 = next.메모;
|
||||
if (next.켜쌓기율 === null) delete row.켜쌓기율;
|
||||
else row.켜쌓기율 = next.켜쌓기율;
|
||||
@@ -434,6 +476,11 @@ export function mountLaborDetail(
|
||||
variant: "filled",
|
||||
onClick: () =>
|
||||
pickMaterial((m) => {
|
||||
if (insideKeys((calc ?? last)?.줄).has(m.코드))
|
||||
return showToast(
|
||||
tc("Labor_InLogicMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
|
||||
"error",
|
||||
);
|
||||
if (cur.자재.some((x) => x.코드 === m.코드))
|
||||
return showToast(
|
||||
tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_LogicMats.ts
|
||||
* 자재목록 표의 로직 자재 줄(103-1) — 담은 로직 맨 위 층 재료 줄(서버 calc `줄[].재료`) · 사용자 자재 줄과 같은 칸
|
||||
* (코드 · 이름 · 규격 · 수량 · 단위 · 가격 다섯 · 단가 / 금액) · 사용자 자재 앞.
|
||||
* · 삭제 없음 · [바꾸기] = M01 자재품목 표에서 고름(문서 `담은로직[].고름` · 엔진 `choosing`) · 바꾼 줄만 [기본](고름 풂)
|
||||
* · 금액은 로직 단가 안이라 합에 다시 안 더함(보기만) · 수량은 로직 것(누름 · 알약 없음)
|
||||
* · 하위 로직 안 재료는 안 보임(엔진이 맨 위 층만 고름)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { openPickTable } from "../M01_MasterData/M01_MasterData_UI_Logic_PickTable";
|
||||
import { headWord } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Modal";
|
||||
import type { LaborCalc, LaborRow } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
|
||||
|
||||
export interface LogicMatHandlers {
|
||||
/** [바꾸기]로 고른 품목 키 — `row` = 담은 로직 차례 · `at` = 호표 차례 */
|
||||
swap: (row: number, at: number, key: string) => void;
|
||||
/** [기본] — 그 줄 고름 풂 */
|
||||
reset: (row: number, at: number) => void;
|
||||
}
|
||||
|
||||
const num = (v: number | null): string =>
|
||||
v === null
|
||||
? "—"
|
||||
: Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
/** 담은 로직 차례대로 그 줄 로직 자재 — calc 답이 없으면 빈 것(답이 오면 다시 그림) */
|
||||
export function logicMatRows(
|
||||
rows: LaborRow[],
|
||||
calc: LaborCalc | null,
|
||||
wait: boolean,
|
||||
on?: LogicMatHandlers,
|
||||
): HTMLElement[] {
|
||||
return rows.flatMap((row, r) => {
|
||||
const line = row.참조 ? undefined : calc?.줄.find((l) => lineId(l) === lineId(row));
|
||||
return (line?.재료 ?? []).map((mat) => matRow(row, r, mat, wait, on));
|
||||
});
|
||||
}
|
||||
|
||||
function matRow(
|
||||
row: LaborRow,
|
||||
r: number,
|
||||
mat: LogicMat,
|
||||
wait: boolean,
|
||||
on?: LogicMatHandlers,
|
||||
): HTMLElement {
|
||||
const item = mat.품목;
|
||||
const name = item?.이름 ?? mat.이름;
|
||||
const owner = `${row.별칭 ?? row.로직} · ${mat.이름}`;
|
||||
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
|
||||
const base = !!item?.값칸 && item.값칸 === slot;
|
||||
const v = mat.가격?.[slot];
|
||||
return el("td", {
|
||||
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
|
||||
text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v),
|
||||
attrs: { "data-label": slot, "data-price-slot": slot },
|
||||
});
|
||||
};
|
||||
const money = moneyCell(mat.단가, mat.금액, wait, tc("Labor_AppliedSum"));
|
||||
money.title = tc("LogicMat_Inside");
|
||||
const tools = on
|
||||
? [
|
||||
el("td", {
|
||||
className: "m02-labrow__move",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "ui-btn-group",
|
||||
children: [
|
||||
createButton({
|
||||
label: tc("LogicMat_Swap"),
|
||||
variant: "ghost",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
openPickTable({
|
||||
kind: "재료",
|
||||
title: `${tc("LogicMat_PickTitle")} · ${mat.이름}`,
|
||||
seed: headWord(name),
|
||||
alt: [headWord(mat.이름)],
|
||||
isCurrent: (x) => String(x["키"]) === item?.키,
|
||||
onPick: (x) => on.swap(r, mat.차례, String(x["키"])),
|
||||
});
|
||||
},
|
||||
}),
|
||||
...(mat.고름
|
||||
? [
|
||||
createButton({
|
||||
label: tc("LogicMat_Reset"),
|
||||
variant: "ghost",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
on.reset(r, mat.차례);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
return el("tr", {
|
||||
className: `m02-labrow__row m02-labrow__logic-mat${mat.고름 ? " is-picked-mat" : ""}`,
|
||||
attrs: { "data-labor-logic-mat": `${row.로직}|${mat.차례}` },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__code", text: item?.키 ?? "—" }),
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
children: [
|
||||
el("strong", { text: name, attrs: { title: name } }),
|
||||
el("small", {
|
||||
className: "m02-labrow__key",
|
||||
text: mat.고름 ? `${owner} · ${tc("LogicMat_Picked")}` : owner,
|
||||
attrs: { title: owner },
|
||||
}),
|
||||
...(mat.까닭 ? [el("small", { className: "m02-labrow__empty", text: mat.까닭 })] : []),
|
||||
],
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__spec",
|
||||
text: item?.규격 ?? "",
|
||||
attrs: { title: item?.규격 ?? "" },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
text: wait ? "…" : num(mat.수량),
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__unit",
|
||||
text: mat.단위,
|
||||
attrs: { "data-label": tc("Labor_Unit") },
|
||||
}),
|
||||
...PRICE_SLOTS.map(priceCell),
|
||||
money,
|
||||
...tools,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_RowDrag.ts
|
||||
* 일위대가 담은 로직 줄 통째 끌기(102-2) — 줄 · 옵션 값 줄 · 하위 줄 어디서나(손잡이도 같은 길).
|
||||
* 6px 움직여야 끌기(B05 `Profile_Wheel` 선례) · 그 아래는 누름(열고 닫기) · 끈 뒤 누름 한 번 삼킴(열림 · 닫힘 그대로) ·
|
||||
* 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함(별칭 글 고르기 85-3) · 놓은 줄 자리로 `move` ·
|
||||
* 끄는 동안 표를 다시 그리지 않음(`afterRowDrag` — 끝난 뒤 한 번).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 이만큼 움직여야 끌기 — 그 아래는 누름(B05 와 같음 · 4px 은 손떨림에 넘어감) */
|
||||
export const DRAG_THRESHOLD_PX = 6;
|
||||
const BLOCK = "button, input, select, textarea, a, [data-labor-qty-open]";
|
||||
const HEAD = "tr[data-labor-logic]";
|
||||
|
||||
let active = false;
|
||||
const queued = new Map<Element, () => void>();
|
||||
|
||||
/** 다시 그림 — 끄는 동안이면 끝난 뒤로 미룸(자리마다 마지막 것만) */
|
||||
export function afterRowDrag(host: Element, draw: () => void): void {
|
||||
if (active) return void queued.set(host, draw);
|
||||
queued.delete(host);
|
||||
draw();
|
||||
}
|
||||
|
||||
/** 문턱 — 가로 · 세로 어느 쪽이든 6px 이면 끌기 */
|
||||
export const pastThreshold = (dx: number, dy: number): boolean =>
|
||||
Math.abs(dx) >= DRAG_THRESHOLD_PX || Math.abs(dy) >= DRAG_THRESHOLD_PX;
|
||||
|
||||
/** 그 줄이 속한 로직 줄(옵션 값 줄 · 하위 줄은 위로 올라가 찾음) */
|
||||
const headOf = (tr: Element | null | undefined): Element | null => {
|
||||
let t = tr ?? null;
|
||||
while (t && !t.matches(HEAD)) t = t.previousElementSibling;
|
||||
return t;
|
||||
};
|
||||
|
||||
/** 로직 줄 무리 — 로직 줄 + 옵션 값 줄 + 하위 줄 */
|
||||
const groupOf = (head: Element): Element[] => {
|
||||
const out = [head];
|
||||
for (let t = head.nextElementSibling; t && !t.matches(HEAD); t = t.nextElementSibling)
|
||||
out.push(t);
|
||||
return out;
|
||||
};
|
||||
|
||||
/** `root` = 표 감싼 칸 · 줄 차례 = tbody 안 로직 줄 차례(같은 로직 두 줄이어도 자리로 셈) */
|
||||
export function attachRowDrag(root: HTMLElement, move: (from: number, to: number) => void): void {
|
||||
let id: number | null = null;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let moved = false;
|
||||
let from = -1;
|
||||
let to = -1;
|
||||
let marks: Element[] = [];
|
||||
const heads = (): Element[] => [...root.querySelectorAll(`tbody > ${HEAD}`)];
|
||||
const unmark = (): void => {
|
||||
for (const m of marks) m.classList.remove("is-dragging", "is-dragover", "is-dragunder");
|
||||
marks = [];
|
||||
};
|
||||
const mark = (els: Element[], cls: string): void => {
|
||||
for (const e of els) e.classList.add(cls);
|
||||
marks.push(...els);
|
||||
};
|
||||
|
||||
const end = (drop: boolean): void => {
|
||||
const pid = id;
|
||||
id = null;
|
||||
if (pid === null || !moved) return;
|
||||
moved = false;
|
||||
if (root.hasPointerCapture(pid)) root.releasePointerCapture(pid);
|
||||
unmark();
|
||||
root.style.userSelect = "";
|
||||
root.style.cursor = "";
|
||||
active = false;
|
||||
// 끈 뒤 누름 한 번 삼킴(캡처 · 다음 프레임에 걷음) — 열림 · 닫힘 그대로
|
||||
const swallow = (e: MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener("click", swallow, { capture: true, once: true });
|
||||
requestAnimationFrame(() => window.removeEventListener("click", swallow, { capture: true }));
|
||||
if (drop && to >= 0 && to !== from) move(from, to);
|
||||
const later = [...queued.values()];
|
||||
queued.clear();
|
||||
for (const draw of later) draw();
|
||||
};
|
||||
|
||||
root.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0 || moved) return;
|
||||
const t = e.target as Element;
|
||||
if (t.closest(BLOCK)) return;
|
||||
const head = headOf(t.closest("tbody > tr"));
|
||||
from = head ? heads().indexOf(head) : -1;
|
||||
if (from < 0) return;
|
||||
id = e.pointerId;
|
||||
x = e.clientX;
|
||||
y = e.clientY;
|
||||
to = -1;
|
||||
});
|
||||
|
||||
root.addEventListener("pointermove", (e) => {
|
||||
if (id !== e.pointerId) return;
|
||||
if (!moved) {
|
||||
if (!(e.buttons & 1)) return void (id = null); // 누름 없이 들어옴 — 끌기 아님
|
||||
if (!pastThreshold(e.clientX - x, e.clientY - y)) return;
|
||||
moved = true;
|
||||
active = true;
|
||||
// 이제부터 끌기 — 표 밖으로 나가도 이어지게 포인터를 잡음 · 글자 고름 막음
|
||||
root.setPointerCapture(e.pointerId);
|
||||
root.style.userSelect = "none";
|
||||
root.style.cursor = "grabbing";
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
e.preventDefault();
|
||||
unmark();
|
||||
const list = heads();
|
||||
if (list[from]) mark(groupOf(list[from]), "is-dragging");
|
||||
const over = headOf(document.elementFromPoint(e.clientX, e.clientY)?.closest("tbody > tr"));
|
||||
to = over ? list.indexOf(over) : -1;
|
||||
if (!over || to < 0 || to === from) return;
|
||||
// 위로 = 놓을 줄 위 선 · 아래로 = 놓을 줄 무리 아래 선(그 뒤로 감)
|
||||
if (to < from) mark([over], "is-dragover");
|
||||
else mark(groupOf(over).slice(-1), "is-dragunder");
|
||||
});
|
||||
|
||||
root.addEventListener("pointerup", () => end(true));
|
||||
root.addEventListener("pointercancel", () => end(false));
|
||||
root.addEventListener("lostpointercapture", () => end(false));
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
LaborRow,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
@@ -47,6 +48,9 @@ export const kindOf = (row: Pick<LaborRow, "로직" | "참조">): string => {
|
||||
);
|
||||
};
|
||||
|
||||
/** 품셈 책(102-3) — 출처 첫 낱말 */
|
||||
const BOOK = /^(건설|산림)품셈/;
|
||||
|
||||
const num = (v: number): string =>
|
||||
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
@@ -121,7 +125,7 @@ function qtyCell(
|
||||
}
|
||||
|
||||
/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */
|
||||
function moneyCell(
|
||||
export function moneyCell(
|
||||
base: number | null | undefined,
|
||||
applied: number | null | undefined,
|
||||
wait: boolean,
|
||||
@@ -148,9 +152,6 @@ function moneyCell(
|
||||
}
|
||||
|
||||
/** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */
|
||||
/** 끌고 있는 줄 차례(85-3) — 손잡이 dragstart ~ drop */
|
||||
let dragFrom: number | null = null;
|
||||
|
||||
const titled = (td: HTMLElement): HTMLElement => {
|
||||
for (const c of td.children) c.setAttribute("title", c.textContent ?? "");
|
||||
return td;
|
||||
@@ -182,7 +183,7 @@ export interface RowHandlers {
|
||||
ref?: (i: number) => void;
|
||||
/** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */
|
||||
qty?: (i: number) => void;
|
||||
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 손잡이 끌기 = 놓은 줄 자리) */
|
||||
/** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 줄 끌기 102-2 = 놓은 줄 자리) */
|
||||
move?: (from: number, to: number) => void;
|
||||
remove?: (i: number) => void;
|
||||
/** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */
|
||||
@@ -315,6 +316,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : "";
|
||||
const note = [values.text, layer].filter(Boolean).join(" · ");
|
||||
const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null);
|
||||
const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0];
|
||||
const small = (
|
||||
label: string,
|
||||
run: () => void,
|
||||
@@ -328,7 +330,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
run();
|
||||
},
|
||||
});
|
||||
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기(줄 전체는 안 끎 · 별칭 칸 글 고르기)
|
||||
// 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) · 손잡이 = 차례 칸 앞(102-1) · 끌기는 줄 통째(102-2 · `Labor_RowDrag`)
|
||||
const arrow = (mark: string, label: string, to: number): HTMLElement => {
|
||||
const b = small(mark, () => move?.(i, to));
|
||||
b.classList.add("m02-labrow__arrow");
|
||||
@@ -341,17 +343,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
const g = el("span", {
|
||||
className: "m02-labrow__grip",
|
||||
text: "⠿",
|
||||
attrs: { draggable: "true", title: tc("Labor_Drag"), "data-labor-grip": String(i) },
|
||||
});
|
||||
g.addEventListener("click", (e) => e.stopPropagation());
|
||||
g.addEventListener("dragstart", (ev) => {
|
||||
dragFrom = i;
|
||||
ev.dataTransfer?.setData("text/plain", String(i));
|
||||
if (ev.dataTransfer) ev.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
g.addEventListener("dragend", () => {
|
||||
dragFrom = null;
|
||||
attrs: { title: tc("Labor_Drag"), "data-labor-grip": String(i) },
|
||||
});
|
||||
g.addEventListener("click", (e) => e.stopPropagation()); // 손잡이 누름은 열고 닫지 않음
|
||||
return g;
|
||||
};
|
||||
const sel = data.selected === id ? " is-selected" : "";
|
||||
@@ -359,8 +353,18 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel,
|
||||
attrs: { "data-labor-logic": id },
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__no", text: String(i + 1) }),
|
||||
el("td", { className: "m02-labrow__kind", text: kindOf(row) }),
|
||||
el("td", {
|
||||
className: "m02-labrow__no",
|
||||
children: move ? [grip(), String(i + 1)] : [String(i + 1)],
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__kind",
|
||||
children: [
|
||||
kindOf(row),
|
||||
// 102-3 — 단가산출 줄만 품셈 책(출처 첫 낱말)
|
||||
...(book ? [el("small", { className: "m02-labrow__book", text: book })] : []),
|
||||
],
|
||||
}),
|
||||
titled(
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
@@ -406,7 +410,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
el("div", {
|
||||
className: "m02-labrow__order",
|
||||
children: [
|
||||
grip(),
|
||||
arrow("▲", tc("Up"), i - 1),
|
||||
arrow("▼", tc("Down"), i + 1),
|
||||
],
|
||||
@@ -437,21 +440,6 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
],
|
||||
});
|
||||
if (on) tr.addEventListener("click", () => on.open(i));
|
||||
if (move) {
|
||||
tr.addEventListener("dragover", (ev) => {
|
||||
if (dragFrom === null || dragFrom === i) return;
|
||||
ev.preventDefault();
|
||||
tr.classList.add("is-dragover");
|
||||
});
|
||||
tr.addEventListener("dragleave", () => tr.classList.remove("is-dragover"));
|
||||
tr.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
tr.classList.remove("is-dragover");
|
||||
const from = dragFrom;
|
||||
dragFrom = null;
|
||||
if (from !== null && from !== i) move(from, i);
|
||||
});
|
||||
}
|
||||
const subs = sel ? subRows(line, wait, tools) : [];
|
||||
if (!note) return [tr, ...subs];
|
||||
// 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐
|
||||
@@ -509,13 +497,16 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
...COSTS.map((c) => tc(HEAD[c])),
|
||||
...(tools ? [tc("Col_Move")] : []),
|
||||
];
|
||||
return data.rows.length
|
||||
? table(heads, trs, foot)
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
||||
if (!data.rows.length) return el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
||||
const out = table(heads, trs, foot);
|
||||
if (move) attachRowDrag(out, move);
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface MatTableData {
|
||||
mats: LaborMat[];
|
||||
/** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */
|
||||
lead?: HTMLElement[];
|
||||
calc: LaborCalc | null;
|
||||
fail?: string | null;
|
||||
cells?: ReadonlyMap<string, string>;
|
||||
@@ -609,7 +600,8 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
|
||||
...(remove ? [tc("Col_Move")] : []),
|
||||
];
|
||||
return data.mats.length
|
||||
? table(heads, trs, [])
|
||||
const lead = data.lead ?? [];
|
||||
return data.mats.length || lead.length
|
||||
? table(heads, [...lead, ...trs], [])
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
|
||||
}
|
||||
|
||||
@@ -110,6 +110,16 @@ const TEXT = {
|
||||
Labor_Spec: ["규격", "Spec"],
|
||||
Labor_Already: ["이미 담은 단가산출 로직 — {value}", "Already added — {value}"],
|
||||
Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"],
|
||||
// 로직 자재(103-1)
|
||||
Labor_InLogicMat: ["로직 자재에 있는 품목 — {value}", "Already a logic material — {value}"],
|
||||
LogicMat_Swap: ["바꾸기", "Change"],
|
||||
LogicMat_Reset: ["기본", "Default"],
|
||||
LogicMat_PickTitle: ["로직 자재 바꾸기", "Change logic material"],
|
||||
LogicMat_Inside: [
|
||||
"로직 단가 안 금액 — 합계에 다시 안 더함",
|
||||
"Inside the logic price — not added again",
|
||||
],
|
||||
LogicMat_Picked: ["바꾼 품목", "Changed item"],
|
||||
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
|
||||
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
|
||||
In_Memo: ["메모", "Memo"],
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type LaborDoc,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
|
||||
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
@@ -92,7 +93,10 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH
|
||||
onRow ? { open: logicOpen } : undefined,
|
||||
),
|
||||
el("h4", { className: "m02-labview__title", text: tc("Labor_Mats") }),
|
||||
matTable({ mats: d.자재, calc, fail, cells }, onRow ? { open: matOpen } : undefined),
|
||||
matTable(
|
||||
{ mats: d.자재, calc, fail, cells, lead: logicMatRows(d.담은로직, calc, !calc && !fail) },
|
||||
onRow ? { open: matOpen } : undefined,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -237,20 +237,39 @@ def _total(sums: Any) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def _sum_state(calc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""합계 칸 색 상태(101-1) — 붉음 `error` > 노랑 `short` > 초록 `ok` + 까닭 글.
|
||||
붉음 = 기준이 안 나온 돈 줄(까닭 있음) · 기준 단가 없는 자재 / 노랑 = 수량 없는 줄 · 자재 /
|
||||
돈 아닌 로직 줄(`결과`)은 판정에서 뺌."""
|
||||
bad = short = 0
|
||||
for one in (*(calc.get("줄") or []), *(calc.get("자재") or [])):
|
||||
if not isinstance(one, dict):
|
||||
continue
|
||||
if one.get("기준", one.get("기준단가")) is None:
|
||||
bad += 1 if one.get("까닭") else 0
|
||||
elif one.get("수량") is None:
|
||||
short += 1
|
||||
why = [f"{t} {n}" for t, n in (("기준 단가 없음", bad), ("수량 없는 줄", short)) if n]
|
||||
return {
|
||||
"합계상태": "error" if bad else "short" if short else "ok",
|
||||
"합계까닭": " · ".join(why) or "모두 반영",
|
||||
}
|
||||
|
||||
|
||||
def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 ·
|
||||
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙)."""
|
||||
담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙) · `합계상태`(101-1)."""
|
||||
if not doc.get("담은로직"):
|
||||
return {}
|
||||
calc = doc.get("계산값") or {}
|
||||
if total := _total(calc.get("반영합")):
|
||||
return {"일위대가합계": total, "합계기준": False}
|
||||
return {"일위대가합계": total, "합계기준": False, **_sum_state(calc)}
|
||||
base = _total(calc.get("기준합"))
|
||||
return {} if base is None else {"일위대가합계": base, "합계기준": True}
|
||||
return {} if base is None else {"일위대가합계": base, "합계기준": True, **_sum_state(calc)}
|
||||
|
||||
|
||||
def structure_numbers() -> dict[str, dict[str, Any]]:
|
||||
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에
|
||||
"""`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준, 합계상태, 합계까닭)}}` — 집계표 줄 · 좌측 줄이 한 번에
|
||||
받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만)."""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for kind, key, _, _ in PARTS:
|
||||
|
||||
@@ -58,7 +58,6 @@ const TEXT = {
|
||||
noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음",
|
||||
pillsFailed: "일위대가 알약을 못 받음 — {value}",
|
||||
discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?",
|
||||
baseNote: "(기준)",
|
||||
};
|
||||
|
||||
/** 집계표 줄 열쇠 → 문서 */
|
||||
@@ -74,11 +73,17 @@ const fill = (text: string, value: string): string => text.replace("{value}", va
|
||||
/** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */
|
||||
export type ColumnNumbers = Record<string, StructureNumbers>;
|
||||
|
||||
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */
|
||||
/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 없으면 기준합 · 계산값 없음 = 빈 글(「—」) · 색은 `laborSumState` */
|
||||
export function laborSumText(numbers: StructureNumbers | undefined): string {
|
||||
if (typeof numbers?.일위대가합계 !== "number") return "";
|
||||
const text = numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
||||
return numbers.합계기준 ? `${text} ${TEXT.baseNote}` : text;
|
||||
return numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
/** 합계 칸 색 상태 · 툴팁 까닭(101-1) — 계산값 없음 · 담은 것 없음 = 색 없음 */
|
||||
export function laborSumState(
|
||||
numbers: StructureNumbers | undefined,
|
||||
): { state: string; why: string } | undefined {
|
||||
return numbers?.합계상태 ? { state: numbers.합계상태, why: numbers.합계까닭 ?? "" } : undefined;
|
||||
}
|
||||
|
||||
/** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
comboMerge,
|
||||
comboView,
|
||||
createStructurePanel,
|
||||
laborSumState,
|
||||
laborSumText,
|
||||
pillsFor,
|
||||
STRUCTURE_TABLE,
|
||||
@@ -372,6 +373,7 @@ export function createMain(
|
||||
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
|
||||
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
|
||||
laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3
|
||||
laborSumState: (colId: string) => laborSumState(struct.numbers()[colId]), // 101-1
|
||||
combo: {
|
||||
...(readOnly || !extras.onHeadRename
|
||||
? {}
|
||||
|
||||
@@ -370,6 +370,8 @@ function fordSet(
|
||||
const pipeCount = count ? Math.max(Math.trunc(count), 1) : 1;
|
||||
const wingIn = wingSpec(values, defaults, "in");
|
||||
const wingOut = wingSpec(values, defaults, "out");
|
||||
// 유입 날개벽 수평 여유 — 흙막이 수평공간과 같은 칸(107-3) · 빈 값 = 화면 기본값
|
||||
if (wingIn.installed) wingIn.berm_m = num(values.inlet_revet_berm_m, null);
|
||||
const spec: CulvertSetSpec = {
|
||||
type: "ford",
|
||||
pipe_kind: kind,
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_form",
|
||||
"label": "유입 기슭막이 형태",
|
||||
"label": "유입 흙막이 형태",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"돌쌓기(찰)",
|
||||
@@ -176,7 +176,7 @@
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_length_m",
|
||||
"label": "유입 기슭막이 길이",
|
||||
"label": "유입 흙막이 길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 10,
|
||||
@@ -184,7 +184,7 @@
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_before_m",
|
||||
"label": "유입 기슭막이 기준측점 전",
|
||||
"label": "유입 흙막이 기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
@@ -193,7 +193,7 @@
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_after_m",
|
||||
"label": "유입 기슭막이 기준측점 후",
|
||||
"label": "유입 흙막이 기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
@@ -202,7 +202,7 @@
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_height_m",
|
||||
"label": "유입 기슭막이 높이",
|
||||
"label": "유입 흙막이 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1.5,
|
||||
@@ -223,7 +223,7 @@
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_form",
|
||||
"label": "유출 기슭막이 형태",
|
||||
"label": "유출 흙막이 형태",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"돌쌓기(찰)",
|
||||
@@ -240,7 +240,7 @@
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_length_m",
|
||||
"label": "유출 기슭막이 길이",
|
||||
"label": "유출 흙막이 길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 10,
|
||||
@@ -248,7 +248,7 @@
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_before_m",
|
||||
"label": "유출 기슭막이 기준측점 전",
|
||||
"label": "유출 흙막이 기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
@@ -257,7 +257,7 @@
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_after_m",
|
||||
"label": "유출 기슭막이 기준측점 후",
|
||||
"label": "유출 흙막이 기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 5,
|
||||
@@ -266,7 +266,7 @@
|
||||
},
|
||||
{
|
||||
"key": "outlet_revet_height_m",
|
||||
"label": "유출 기슭막이 높이",
|
||||
"label": "유출 흙막이 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1.5,
|
||||
@@ -277,7 +277,7 @@
|
||||
},
|
||||
{
|
||||
"key": "revet_foundation",
|
||||
"label": "기슭막이 기초",
|
||||
"label": "흙막이 기초",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"기초유",
|
||||
@@ -317,7 +317,7 @@
|
||||
},
|
||||
{
|
||||
"key": "revet_foundation",
|
||||
"label": "기슭막이 기초",
|
||||
"label": "흙막이 기초",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"기초유",
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/* 107 세월교 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
|
||||
* 입력 = 73-4 픽스처(세월교 173.09 · 박스 352.14) · 결과 = 표준출력 JSON 한 벌. 짝 = `test_107_ford_free.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
// 화면 전용 모듈(토스트 · 글) — 기하 계산엔 안 쓰임
|
||||
const STUBS = {
|
||||
"@ui/ui_template_toast": { showToast: () => undefined },
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
if (full.endsWith(".json")) {
|
||||
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
cache.set(full, data);
|
||||
return data;
|
||||
}
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
||||
for (const [prefix, dir] of Object.entries(alias))
|
||||
if (request.startsWith(prefix))
|
||||
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
const resolve = (base) =>
|
||||
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
||||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||||
) ?? `${base}.ts`;
|
||||
|
||||
const B06 = (f) => path.join(ROOT, "B06_Section", f);
|
||||
const out = {};
|
||||
|
||||
// 107-1 — 세월교 · 박스를 고르면 같은 측점 벽 고름을 풂(푸는 것은 안 부름)
|
||||
const { createBodyControls } = loadTs(B06("B06_Section_UI_Page_Ford_Controls.ts"));
|
||||
const released = [];
|
||||
const bodies = createBodyControls(
|
||||
{
|
||||
sectionAt: () => undefined,
|
||||
patchCachedDesign: () => undefined,
|
||||
refreshCard: () => undefined,
|
||||
queuePipeOptions: () => undefined,
|
||||
round1: (v) => Math.round(v * 10) / 10,
|
||||
clampMove: (v) => v,
|
||||
releaseWall: (chainageM) => released.push(chainageM),
|
||||
},
|
||||
() => null,
|
||||
);
|
||||
bodies.ford.control.select(173.09, "inlet");
|
||||
bodies.ford.control.select(173.09, null);
|
||||
bodies.box.control.select(352.14, "left");
|
||||
out.released = released;
|
||||
|
||||
// 107-2 — 원지반을 바꾼 사본: 세월교 바닥 = 노면 − (관경 + 토피) · 박스 본체 · 끝 · 날개 · 흙막이 자리 그대로
|
||||
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts.ts"));
|
||||
const fixture = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
|
||||
);
|
||||
const sections = fixture.cross_sections;
|
||||
const at = (c) => sections.find((x) => Math.abs(x.chainage_m - c) < 0.6);
|
||||
const clone = (v) => JSON.parse(JSON.stringify(v));
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const pts = (list) => (list ?? []).map((p) => [r3(p.offset), r3(p.elevation)]);
|
||||
/** 원지반 사본 — [from, to] 안 샘플을 dz 만큼 옮김. */
|
||||
const shiftGround = (x, dz, from = -Infinity, to = Infinity) => {
|
||||
for (const p of x.samples)
|
||||
if (p.offset_m >= from && p.offset_m <= to && p.elevation_m != null) p.elevation_m += dz;
|
||||
};
|
||||
const GROUNDS = {
|
||||
base: () => undefined,
|
||||
bedDown2: (x) => shiftGround(x, -2, -2, 2),
|
||||
allUp05: (x) => shiftGround(x, 0.5),
|
||||
allDown1: (x) => shiftGround(x, -1),
|
||||
};
|
||||
const walls = (culvert) =>
|
||||
(culvert?.walls ?? []).filter((w) => w.role !== "extra").map((w) => pts(w.points));
|
||||
function fordShape(base, edit, ground) {
|
||||
const x = clone(base);
|
||||
edit(x);
|
||||
ground(x);
|
||||
const { ford, culvert } = computeStoredLayouts(
|
||||
x,
|
||||
sections.map((y) => (y === base ? x : y)),
|
||||
);
|
||||
return {
|
||||
sides: ford.sides.map((side) => ({
|
||||
role: side.role,
|
||||
parts: side.parts.map((part) => pts(part.points)),
|
||||
slabTop: r3(side.slabTopElevation),
|
||||
// 노면(벽 상단) − 바닥판 상면 = 관경 + 최소 토피
|
||||
depth: r3(side.top.elevation - side.slabTopElevation),
|
||||
})),
|
||||
pipeCover: r3(ford.ford.diameter_m + ford.ford.min_cover_m),
|
||||
slab: pts(ford.slabBridge),
|
||||
pipe: pts(ford.pipe.outer),
|
||||
walls: walls(culvert),
|
||||
};
|
||||
}
|
||||
function boxShape(base, edit, ground) {
|
||||
const x = clone(base);
|
||||
edit(x);
|
||||
ground(x);
|
||||
const { box, culvert } = computeStoredLayouts(
|
||||
x,
|
||||
sections.map((y) => (y === base ? x : y)),
|
||||
);
|
||||
return {
|
||||
top: pts(box.topSlab),
|
||||
bottom: pts(box.bottomSlab),
|
||||
barrel: pts(box.barrel),
|
||||
sides: box.sides.map((side) => ({
|
||||
role: side.role,
|
||||
offset: r3(side.offset),
|
||||
top: r3(side.topElevation),
|
||||
bottom: r3(side.bottomElevation),
|
||||
apron: pts(side.apron),
|
||||
})),
|
||||
walls: walls(culvert),
|
||||
};
|
||||
}
|
||||
const FORD = at(173.087);
|
||||
const BOX = at(352.14);
|
||||
const keep = () => undefined;
|
||||
const inletWing = (x) => {
|
||||
x.culvert.inlet = { role: "inlet", structure: "" };
|
||||
};
|
||||
// 날개벽 박스 — 흙막이 없이 양쪽 날개벽(에이프런 1.414 m)
|
||||
const noGuard = (x) => {
|
||||
delete x.culvert;
|
||||
for (const wing of [x.box.wing_in, x.box.wing_out])
|
||||
Object.assign(wing, { installed: true, slab_extend_m: 1.414 });
|
||||
};
|
||||
const run = (shape, base, edit) =>
|
||||
Object.fromEntries(Object.entries(GROUNDS).map(([k, g]) => [k, shape(base, edit, g)]));
|
||||
out.ford = { guards: run(fordShape, FORD, keep), inletWing: run(fordShape, FORD, inletWing) };
|
||||
out.box = { guards: run(boxShape, BOX, keep), bare: run(boxShape, BOX, noGuard) };
|
||||
|
||||
// 107-3 — 유입 날개벽 끝이 묻히면 수평 여유(흙막이 수평공간과 같은 칸 · 기본 1 m) 뒤 절토
|
||||
function wingCut(berm) {
|
||||
const x = clone(FORD);
|
||||
delete x.culvert;
|
||||
for (const wing of [x.ford.wing_in, x.ford.wing_out])
|
||||
Object.assign(wing, { installed: true, slab_extend_m: 1.414 });
|
||||
x.ford.wing_in.berm_m = berm;
|
||||
shiftGround(x, 3); // 바닥판 끝을 원지반 안으로
|
||||
const { ford } = computeStoredLayouts(
|
||||
x,
|
||||
sections.map((y) => (y === FORD ? x : y)),
|
||||
);
|
||||
return Object.fromEntries(ford.sides.map((side) => [side.role, pts(side.cutLine)]));
|
||||
}
|
||||
out.wingCut = { auto: wingCut(null), two: wingCut(2), zero: wingCut(0) };
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,302 @@
|
||||
/* PLAN 108 단위검증 헬퍼 — 조정창 끌기 두 축 · 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면.
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌.
|
||||
* 끌기 손잡이(`bindDragHandle`)는 가로채서 창이 넘긴 `snap` 을 그대로 부른다.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
/** 가로챌 모듈 — 아래에서 채운다(창 모듈을 부르기 전에). */
|
||||
let STUBS = {};
|
||||
|
||||
// ── 화면 그릇 흉내 — 조정창이 쓰는 만큼만 ─────────────────────────────────────
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this._text = "";
|
||||
this._classes = new Set();
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...names) => names.forEach((n) => self._classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
|
||||
toggle: (name, force) => {
|
||||
const on = force === undefined ? !self._classes.has(name) : !!force;
|
||||
on ? self._classes.add(name) : self._classes.delete(name);
|
||||
return on;
|
||||
},
|
||||
contains: (name) => self._classes.has(name),
|
||||
};
|
||||
}
|
||||
set className(value) {
|
||||
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this._classes].join(" ");
|
||||
}
|
||||
set textContent(value) {
|
||||
this._text = String(value);
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => this.children.push(k));
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
}
|
||||
addEventListener() {}
|
||||
querySelectorAll(selector) {
|
||||
const found = [];
|
||||
const walk = (node) =>
|
||||
node.children.forEach((kid) => {
|
||||
if (kid.classList && kid.classList.contains(selector.slice(1))) found.push(kid);
|
||||
walk(kid);
|
||||
});
|
||||
walk(this);
|
||||
return found;
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] ?? null;
|
||||
}
|
||||
}
|
||||
globalThis.document = {
|
||||
createElement: (tag) => new FakeNode(tag),
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} };
|
||||
globalThis.requestAnimationFrame = (fn) => fn();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
|
||||
return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B06 = (name) => path.join(ROOT, "B06_Section", `${name}.ts`);
|
||||
const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base"));
|
||||
/** 창이 넘긴 끌기 deps — 마지막 것이 지금 창. */
|
||||
const drags = [];
|
||||
STUBS = {
|
||||
"./B06_Section_UI_Section_Common": { L: (key) => key },
|
||||
"@ui/ui_template_elements": { showToast: () => {} },
|
||||
"./B06_Section_UI_Cross_Panel_Base": {
|
||||
...base,
|
||||
bindDragHandle: (root, deps) => {
|
||||
drags.push(deps);
|
||||
return { attach: () => {}, detach: () => {} };
|
||||
},
|
||||
},
|
||||
};
|
||||
const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel"));
|
||||
const { buildBoxPanel } = loadTs(B06("B06_Section_UI_Cross_Box_Panel"));
|
||||
const { buildFordPanel } = loadTs(B06("B06_Section_UI_Cross_Ford_Panel"));
|
||||
const { structurePanelDeps } = loadTs(B06("B06_Section_UI_Cross_View_Structure"));
|
||||
const { placePipeWall } = loadTs(B06("B06_Section_UI_Cross_Culvert_Solve"));
|
||||
const { boxHitBands } = loadTs(B06("B06_Section_UI_Cross_Box"));
|
||||
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const snapOut = (s) => (s ? { leftM: r3(s.leftM), downM: r3(s.downM), text: s.text } : null);
|
||||
|
||||
// ── 벽 · 집수정 창 — 실제 배선(structurePanelDeps)으로 ─────────────────────────
|
||||
function wallCase({ key, structure = "revet", outward, adjust, appliedD = 0, basin, guard }) {
|
||||
const updates = [];
|
||||
const ctx = {
|
||||
section: { chainage_m: 100 },
|
||||
adjustChainage: () => 100,
|
||||
isLinked: false,
|
||||
revetOffset: { update: (at, role, patch) => updates.push({ role, ...patch }) },
|
||||
inletStructure: {
|
||||
valueFor: () => structure,
|
||||
adjustFor: () => basin ?? { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 },
|
||||
updateAdjust: (at, next) => updates.push({ basin: next }),
|
||||
},
|
||||
adjustOf: () => adjust,
|
||||
outwardOf: () => outward,
|
||||
heightOfWall: () => 2,
|
||||
drawnWallKeys: () => [key],
|
||||
formOfWall: () => "돌쌓기(메)",
|
||||
appliedD: () => new Map([[key, appliedD]]),
|
||||
inletIsBasin: () => structure !== "revet",
|
||||
hiddenPipe: () => false,
|
||||
bodyGuard: () => !!guard,
|
||||
fordGuard: () => false,
|
||||
inletOptions: () => ({ revetAllowed: true, basinLUAllowed: true }),
|
||||
extraState: () => ({ canAdd: true, count: 1 }),
|
||||
basinExtraState: () => ({ canAdd: false, count: 0 }),
|
||||
activeRevet: () => key,
|
||||
setActiveRevet: () => {},
|
||||
toggleRevet: () => {},
|
||||
scrollKey: "t",
|
||||
};
|
||||
const panel = buildStructurePanel(structurePanelDeps(ctx));
|
||||
panel.show(key);
|
||||
const drag = drags[drags.length - 1];
|
||||
return {
|
||||
panel,
|
||||
snap: (leftM, downM) => {
|
||||
const s = drag.snap(leftM, downM);
|
||||
if (s?.text) s.apply();
|
||||
return { snap: snapOut(s), update: updates.pop() ?? null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 배관 기준벽 자리(`placePipeWall`) — 미리 보기 = 결과 판정용. 화면 왼쪽 = offset +. */
|
||||
const wallAt = (outward, adjust) =>
|
||||
placePipeWall({
|
||||
autoOffset: 5 * outward,
|
||||
outward,
|
||||
height: 2,
|
||||
baseElevation0: 98,
|
||||
edgeElevation: 100,
|
||||
invertCap: 99,
|
||||
adjust: { x: adjust.x, d: adjust.d, h: null, m: null },
|
||||
defaultD: 0,
|
||||
});
|
||||
const shiftOf = (outward, from, to) => {
|
||||
const a = wallAt(outward, from);
|
||||
const b = wallAt(outward, to);
|
||||
return { leftM: r3(b.anchorOffset - a.anchorOffset), downM: r3(a.topElevation - b.topElevation) };
|
||||
};
|
||||
|
||||
const out = { wall: {}, box: {}, ford: {} };
|
||||
for (const outward of [1, -1]) {
|
||||
const from = { x: 0, d: 2, h: null, m: null };
|
||||
const { snap, panel } = wallCase({ key: "outlet", outward, adjust: from });
|
||||
// 대각 끌기 — 화면 왼쪽 3.3 · 아래 1.7 (outward = 1 기준), 반대편은 화면 좌우만 뒤집음
|
||||
const got = snap(3.3 * outward, 1.7);
|
||||
out.wall[`diag${outward}`] = {
|
||||
...got,
|
||||
result: got.update ? shiftOf(outward, from, got.update) : null,
|
||||
overlayHead: panel.root.children[0].children.map((c) => c.className),
|
||||
};
|
||||
}
|
||||
// 한계 — 사면 위로 넘김 · 안쪽으로 넘김 → 가장 가까운 허용 자리(d 0 · x 0)
|
||||
out.wall.limit = wallCase({
|
||||
key: "outlet",
|
||||
outward: 1,
|
||||
adjust: { x: 0.5, d: 0.5, h: null, m: null },
|
||||
}).snap(-5, -3);
|
||||
// 흙막이(세월교 · BOX) — 본체 끝이 0점이라 안쪽(음수 x)도 됨 · ±10 m 끝
|
||||
out.wall.guard = wallCase({
|
||||
key: "outlet",
|
||||
outward: 1,
|
||||
adjust: { x: 0, d: 0, h: null, m: null },
|
||||
guard: true,
|
||||
}).snap(-14, 0);
|
||||
// 다단 벽 — ▲▼ 순수 수직 · 0.1 걸음 · 선반 x ≥ 1.2·d 가 따라 나감
|
||||
out.wall.extra = wallCase({
|
||||
key: "extra0",
|
||||
outward: 1,
|
||||
adjust: { x: 0, d: 0, h: null, m: null },
|
||||
}).snap(0.04, 1.0);
|
||||
// 집수정 — 1:1.2 사면 · 0.1 걸음 · 좌우 · 사면 ≥ 0
|
||||
out.wall.basin = wallCase({
|
||||
key: "inlet",
|
||||
structure: "L",
|
||||
outward: -1,
|
||||
adjust: { x: 0, d: null, h: null, m: null },
|
||||
basin: { innerWidthM: 1, innerHeightM: 1.2, lateralM: 0.3, slopeM: 0 },
|
||||
}).snap(-0.86, 0.25);
|
||||
out.wall.basinNone = wallCase({
|
||||
key: "inlet",
|
||||
structure: "L",
|
||||
outward: -1,
|
||||
adjust: { x: 0, d: null, h: null, m: null },
|
||||
}).snap(0.02, -0.01);
|
||||
|
||||
// ── BOX 창 — 길이(화면 좌 +) · 표고 ───────────────────────────────────────────
|
||||
function boxCase(role, adjust, leftM, downM) {
|
||||
const moves = [];
|
||||
const panel = buildBoxPanel({
|
||||
adjustFor: () => adjust,
|
||||
nudgeLength: () => {},
|
||||
nudgeRise: () => {},
|
||||
move: (r, screen, rise) => moves.push({ role: r, screen: r3(screen), rise: r3(rise) }),
|
||||
reset: () => {},
|
||||
wingRoleFor: () => "inlet",
|
||||
bodyLengthM: () => 5,
|
||||
slopeRatio: () => null,
|
||||
close: () => {},
|
||||
});
|
||||
panel.show(role);
|
||||
const s = drags[drags.length - 1].snap(leftM, downM);
|
||||
if (s?.text) s.apply();
|
||||
return {
|
||||
snap: snapOut(s),
|
||||
move: moves[0] ?? null,
|
||||
value: panel.root.children[1].children.map((c) => c.className || "line"),
|
||||
};
|
||||
}
|
||||
out.box.left = boxCase("left", { lengthM: 0, riseM: 0 }, 0.47, 0.33);
|
||||
out.box.right = boxCase("right", { lengthM: 1, riseM: 0 }, -0.47, -0.21);
|
||||
out.box.shorten = boxCase("left", { lengthM: 0.2, riseM: 9.9 }, -0.9, -0.5);
|
||||
|
||||
// ── 세월교 창 — 좌우 + 1:1.2 사면 · 0.1 걸음 ─────────────────────────────────────
|
||||
function fordCase(outward, adjust, leftM, downM) {
|
||||
const moves = [];
|
||||
const panel = buildFordPanel({
|
||||
adjustFor: () => adjust,
|
||||
heightFor: () => 1.5,
|
||||
nudge: () => {},
|
||||
nudgeSlope: () => {},
|
||||
outwardOf: () => outward,
|
||||
move: (r, screen, slope) => moves.push({ role: r, screen: r3(screen), slope: r3(slope) }),
|
||||
reset: () => {},
|
||||
slabLengthM: () => 3.2,
|
||||
close: () => {},
|
||||
});
|
||||
panel.show("inlet");
|
||||
const s = drags[drags.length - 1].snap(leftM, downM);
|
||||
if (s?.text) s.apply();
|
||||
return {
|
||||
snap: snapOut(s),
|
||||
move: moves[0] ?? null,
|
||||
value: panel.root.children[1].children.map((c) => c.className || "line"),
|
||||
};
|
||||
}
|
||||
out.ford = {
|
||||
diag: fordCase(1, { heightM: null, lateralM: 0, slopeM: 0 }, 0.53, 0.27),
|
||||
inward: fordCase(-1, { heightM: null, lateralM: 0.2, slopeM: 0 }, 0.9, 0),
|
||||
};
|
||||
|
||||
// ── BOX 판정면 — 좌측 끝 날개벽(에이프런 4 → 6) · 우측 끝 날개벽 없음 ───────────
|
||||
const layout = {
|
||||
sides: [
|
||||
{ role: "left", offset: 4, apron: [{ offset: 4 }, { offset: 6 }] },
|
||||
{ role: "right", offset: -4, apron: [] },
|
||||
],
|
||||
topSlab: [{ elevation: 99 }, { elevation: 99 }],
|
||||
bottomSlab: [{ elevation: 96.5 }, { elevation: 96.5 }],
|
||||
};
|
||||
out.hit = Object.fromEntries(
|
||||
boxHitBands(layout).map(([role, points]) => [
|
||||
role,
|
||||
{
|
||||
offsets: [...new Set(points.map((p) => r3(p.offset)))].sort((a, b) => a - b),
|
||||
elevations: [...new Set(points.map((p) => p.elevation))].sort((a, b) => a - b),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -107,6 +107,28 @@ const out = {
|
||||
}),
|
||||
};
|
||||
|
||||
// 107-1 — 유입 날개벽(측벽) 좌우 · 상하를 옮겨도 유출 흙막이 자리 그대로(관 1 m 맞춤 = 기본 자리 기준)
|
||||
const outletWallAt = (inlet) => {
|
||||
const section = clone(FORD);
|
||||
section.culvert.inlet = { role: "inlet", structure: "" };
|
||||
section.design.ford_adjust = { inlet: { heightM: null, ...inlet } };
|
||||
const { ford, culvert } = computeStoredLayouts(
|
||||
section,
|
||||
sections.map((s) => (s === FORD ? section : s)),
|
||||
);
|
||||
const wall = culvert.walls.find((w) => w.role === "outlet");
|
||||
const [right, left] = ford.pipe.outer.slice(2);
|
||||
return {
|
||||
back: r3(wall.bottomBack.offset),
|
||||
label: ford.label.text,
|
||||
lengthM: r3(Math.hypot(left.offset - right.offset, left.elevation - right.elevation)),
|
||||
};
|
||||
};
|
||||
out.inletMoves = [0, 0.1, 0.2, 0.3, 0.5, 1.0].map((lateralM) =>
|
||||
outletWallAt({ lateralM, slopeM: 0 }),
|
||||
);
|
||||
out.inletSlopes = [0.1, 0.5].map((slopeM) => outletWallAt({ lateralM: 0, slopeM }));
|
||||
|
||||
// 박스 352.14 — 흙막이를 바깥으로 밀면 본체 끝이 배면까지 따라감 · 안 밀면 그대로
|
||||
const BOX = sections.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6);
|
||||
function boxFit(edit) {
|
||||
|
||||
@@ -68,7 +68,7 @@ const layoutsOf = (s, base) =>
|
||||
sections.map((x) => (x === base ? s : x)),
|
||||
);
|
||||
|
||||
// ① 91-2 세월교 바닥 — 자동은 하상을 따라감 · 손 값(측벽 높이)은 원지반을 바꿔도 그대로 · 흙막이 기본 높이도
|
||||
// ① 91-2 · 107-2 세월교 바닥 — 기본 · 손 값(측벽 높이)은 원지반을 바꿔도 그대로 · 흙막이 기본 높이도
|
||||
const FORD = at(173.087);
|
||||
function fordFloor(edit) {
|
||||
const s = clone(FORD);
|
||||
|
||||
@@ -186,7 +186,6 @@ for (const [label, key, kind] of [
|
||||
const fordDeps = {
|
||||
adjustFor: () => ({ heightM: null, lateralM: 0, slopeM: 0 }),
|
||||
heightFor: () => 1.5,
|
||||
nudgeHeight: () => {},
|
||||
nudge: () => {},
|
||||
nudgeSlope: () => {},
|
||||
reset: () => {},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/* M02 일위대가 로직 자재(`M02_Labor_LogicMats_Model.ts` `releaseTied`) 헬퍼 —
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {row, before, mats} · stdout = 고친 row JSON. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_Labor_LogicMats_Model.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
process.stdout.write(JSON.stringify(box.exports.releaseTied(input.row, input.before, input.mats)));
|
||||
@@ -0,0 +1,210 @@
|
||||
/* M02 일위대가 줄 통째 끌기(`M02_MasterTemplete_Labor_RowDrag.ts` · 102-2) 단위검증 헬퍼 —
|
||||
* 트랜스파일해 Node 로 돌림 · 작은 가짜 DOM(줄 높이 20px · 셀렉터는 그 파일이 쓰는 꼴만) · JSON 으로 냄. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"M02_MasterTemplete",
|
||||
"M02_MasterTemplete_Labor_RowDrag.ts",
|
||||
);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
|
||||
/* ---- 가짜 DOM ---- */
|
||||
const one = (node, sel) => {
|
||||
const m = /^(\w+)?(?:\[([\w-]+)\])?$/.exec(sel.trim());
|
||||
if (!m) throw new Error(`셀렉터 모름: ${sel}`);
|
||||
return (!m[1] || node.tag === m[1]) && (!m[2] || m[2] in node.attrs);
|
||||
};
|
||||
const matches = (node, sel) =>
|
||||
sel.split(",").some((part) => {
|
||||
const [up, me] = part.includes(">") ? part.split(">") : [null, part];
|
||||
return one(node, me) && (!up || (node.parent && one(node.parent, up)));
|
||||
});
|
||||
class Node {
|
||||
constructor(tag, attrs = {}, parent = null) {
|
||||
this.tag = tag;
|
||||
this.attrs = attrs;
|
||||
this.parent = parent;
|
||||
this.kids = [];
|
||||
this.on = {};
|
||||
this.style = {};
|
||||
const set = new Set();
|
||||
this.classList = {
|
||||
add: (...c) => c.forEach((x) => set.add(x)),
|
||||
remove: (...c) => c.forEach((x) => set.delete(x)),
|
||||
contains: (c) => set.has(c),
|
||||
list: () => [...set],
|
||||
};
|
||||
if (parent) parent.kids.push(this);
|
||||
}
|
||||
matches(sel) {
|
||||
return matches(this, sel);
|
||||
}
|
||||
closest(sel) {
|
||||
for (let n = this; n; n = n.parent) if (n.matches(sel)) return n;
|
||||
return null;
|
||||
}
|
||||
get previousElementSibling() {
|
||||
const k = this.parent.kids;
|
||||
return k[k.indexOf(this) - 1] ?? null;
|
||||
}
|
||||
get nextElementSibling() {
|
||||
const k = this.parent.kids;
|
||||
return k[k.indexOf(this) + 1] ?? null;
|
||||
}
|
||||
querySelectorAll(sel) {
|
||||
const out = [];
|
||||
const walk = (n) => n.kids.forEach((c) => (c.matches(sel) && out.push(c), walk(c)));
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
(this.on[type] ??= []).push(fn);
|
||||
}
|
||||
}
|
||||
|
||||
/* 표 — 줄 0 · 1(옵션 값 줄 · 하위 줄 둘 = 열린 줄) · 2 · 3 */
|
||||
const root = new Node("div");
|
||||
const tbody = new Node("tbody", {}, new Node("table", {}, root));
|
||||
const tr = (attrs) => new Node("tr", attrs, tbody);
|
||||
const rows = [
|
||||
tr({ "data-labor-logic": "A" }),
|
||||
tr({ "data-labor-logic": "B" }),
|
||||
tr({ "data-labor-opt": "B" }),
|
||||
tr({ "data-labor-sub": "" }),
|
||||
tr({ "data-labor-sub": "" }),
|
||||
tr({ "data-labor-logic": "C" }),
|
||||
tr({ "data-labor-logic": "D" }),
|
||||
];
|
||||
const cell = (r, tag = "td", attrs = {}) => new Node(tag, attrs, rows[r]);
|
||||
const td = rows.map((_, r) => cell(r));
|
||||
const btn = new Node("button", {}, cell(1));
|
||||
const input = new Node("input", { "data-labor-alias": "" }, cell(1));
|
||||
const qty = new Node("span", {}, new Node("button", { "data-labor-qty-open": "" }, cell(1)));
|
||||
const grip = new Node("span", { "data-labor-grip": "0" }, td[0]);
|
||||
const Y = (r) => r * 20 + 10;
|
||||
|
||||
let capture = null;
|
||||
root.hasPointerCapture = (id) => capture === id;
|
||||
root.setPointerCapture = (id) => (capture = id);
|
||||
root.releasePointerCapture = () => (capture = null);
|
||||
|
||||
const winClick = [];
|
||||
let frames = [];
|
||||
global.window = {
|
||||
addEventListener: (t, fn) => winClick.push(fn),
|
||||
removeEventListener: (t, fn) => {
|
||||
const i = winClick.indexOf(fn);
|
||||
if (i >= 0) winClick.splice(i, 1);
|
||||
},
|
||||
getSelection: () => ({ removeAllRanges() {} }),
|
||||
};
|
||||
global.requestAnimationFrame = (fn) => frames.push(fn);
|
||||
global.document = { elementFromPoint: (x, y) => td[Math.floor(y / 20)] ?? null };
|
||||
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
const m = box.exports;
|
||||
|
||||
/* ---- 부르기 ---- */
|
||||
const moves = [];
|
||||
m.attachRowDrag(root, (from, to) => moves.push([from, to]));
|
||||
const fire = (type, e) => root.on[type]?.forEach((fn) => fn({ preventDefault() {}, ...e }));
|
||||
const down = (target, y) =>
|
||||
fire("pointerdown", { button: 0, pointerId: 1, clientX: 50, clientY: y, target });
|
||||
const mv = (y, dx = 0) =>
|
||||
fire("pointermove", { pointerId: 1, buttons: 1, clientX: 50 + dx, clientY: y });
|
||||
/** 누름(click) — 창 캡처 삼킴을 먼저 거침 · 남으면 줄 열고 닫기 */
|
||||
const click = () => {
|
||||
let stopped = false;
|
||||
[...winClick].forEach((fn) =>
|
||||
fn({ stopPropagation: () => (stopped = true), preventDefault() {} }),
|
||||
);
|
||||
winClick.length = 0; // once
|
||||
return !stopped;
|
||||
};
|
||||
const frame = () => {
|
||||
const f = frames;
|
||||
frames = [];
|
||||
f.forEach((fn) => fn());
|
||||
};
|
||||
const classes = () => rows.map((r) => r.classList.list().sort().join(" "));
|
||||
|
||||
const out = {};
|
||||
out.threshold = [
|
||||
[5, 0],
|
||||
[6, 0],
|
||||
[0, -6],
|
||||
[5, 5],
|
||||
[-7, 2],
|
||||
].map(([dx, dy]) => m.pastThreshold(dx, dy));
|
||||
|
||||
// 1) 5px 만 움직임 = 누름 — 옮김 없음 · 누름 지나감(열고 닫기)
|
||||
down(td[0], Y(0));
|
||||
mv(Y(0) + 5);
|
||||
fire("pointerup", {});
|
||||
out.small = { moves: moves.length, clickPasses: click(), capture };
|
||||
|
||||
// 2) 줄 A(0) → 줄 C(차례 2) 로 끌기 — 끄는 동안 표시 · 다시 그림 미룸 · 놓으면 move(0,2) · 누름 삼킴
|
||||
let drawn = 0;
|
||||
const host = {};
|
||||
down(td[0], Y(0));
|
||||
mv(Y(0) + 7);
|
||||
m.afterRowDrag(host, () => drawn++);
|
||||
m.afterRowDrag(host, () => drawn++); // 마지막 것만
|
||||
mv(Y(5));
|
||||
out.during = { drawn, capture, classes: classes(), select: root.style.userSelect };
|
||||
fire("pointerup", {});
|
||||
out.drop = { moves: moves.splice(0), drawn, clickPasses: click(), classes: classes() };
|
||||
frame();
|
||||
out.afterFrame = { listeners: winClick.length };
|
||||
|
||||
// 3) 열린 줄 B 의 하위 줄에서 시작 → 위 줄 A 로 = move(1,0) · 표시 = A 위 선 · B 무리 흐림
|
||||
down(td[4], Y(4));
|
||||
mv(Y(0));
|
||||
out.fromSub = { classes: classes() };
|
||||
fire("pointerup", {});
|
||||
out.fromSub.moves = moves.splice(0);
|
||||
click();
|
||||
frame();
|
||||
|
||||
// 4) 줄 A → 열린 줄 B(아래로) — 표시 = B 무리 맨 아래(하위 줄 둘째) 선
|
||||
down(td[0], Y(0));
|
||||
mv(Y(2));
|
||||
out.downMark = classes();
|
||||
fire("pointerup", {});
|
||||
out.downMark.push(JSON.stringify(moves.splice(0)));
|
||||
click();
|
||||
frame();
|
||||
|
||||
// 5) 단추 · 입력칸 · 수량 여는 자리에서는 시작 안 함
|
||||
for (const [name, t] of [
|
||||
["button", btn],
|
||||
["input", input],
|
||||
["qty", qty],
|
||||
]) {
|
||||
down(t, Y(1));
|
||||
mv(Y(6));
|
||||
fire("pointerup", {});
|
||||
out[`block_${name}`] = { moves: moves.splice(0).length, capture, clickPasses: click() };
|
||||
}
|
||||
|
||||
// 6) 손잡이도 같은 길 · 제자리에 놓음 = 옮김 없음 · 누름은 삼킴(열림 그대로)
|
||||
down(grip, Y(0));
|
||||
mv(Y(0) + 8);
|
||||
fire("pointerup", {});
|
||||
out.gripSelf = { moves: moves.splice(0).length, clickPasses: click() };
|
||||
frame();
|
||||
|
||||
// 7) 끄는 중이 아니면 바로 그림
|
||||
let now = 0;
|
||||
m.afterRowDrag(host, () => now++);
|
||||
out.idleDraw = now;
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 106 — 구간형 구조물 범위 강조 · B05/B06 화면 글 「흙막이」.
|
||||
|
||||
• 106-1 구조물을 고르면 시작~종료 안 횡단 카드 전부에 `--span` 클래스 · 카드를 다시 만들어도 유지
|
||||
• 106-2 로케일 · 툴팁 · 정의 label 에 「기슭막이」 없음(독립=D군 · 사방 근거 글 빼고) · 저장 값 · choices 그대로
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
VIEW = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
|
||||
|
||||
def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길에서_칠한다():
|
||||
assert VIEW.count("b06-cross-card--span") == 2 # syncSpanHighlights · buildCrossCard
|
||||
assert re.search(r"selectMark: \(structureId\) => \{\s+spanMarkId = structureId;", VIEW)
|
||||
assert re.search(r"spanMarks = structures;\s+syncSpanHighlights\(\);", VIEW)
|
||||
assert 'card.classList.toggle("b06-cross-card--span", inSpan(section))' in VIEW
|
||||
|
||||
|
||||
def test_범위_판정은_시작_종료가_다른_구간형만():
|
||||
assert "start == null || end == null || start === end" in VIEW
|
||||
|
||||
|
||||
def test_범위_강조_CSS는_선택_강조를_덮지_않는다():
|
||||
css = _read("B06_Section", "B06_Section_UI_Style_Cross.css")
|
||||
assert ".b06-cross-card--span:not(.b06-cross-card--selected)" in css
|
||||
|
||||
|
||||
def test_로케일에_기슭막이는_독립_한_곳뿐():
|
||||
locale = _read("ui_template", "ui_template_locale_b2.ts")
|
||||
korean = [
|
||||
line
|
||||
for line in locale.splitlines()
|
||||
if "기슭막이" in line and not line.strip().startswith(("//", "/*"))
|
||||
]
|
||||
assert len(korean) == 1 and "B06_Cross_Revet_Own" in korean[0]
|
||||
|
||||
|
||||
def test_정의_label은_흙막이_choices_값은_그대로():
|
||||
definition = json.loads(_read("resources", "master_template", "구조물정의.json"))
|
||||
labels, choices = [], []
|
||||
|
||||
def walk(node):
|
||||
if isinstance(node, dict):
|
||||
if isinstance(node.get("label"), str):
|
||||
labels.append(node["label"])
|
||||
if node.get("key") in ("inlet_type", "outlet_type"):
|
||||
choices.append(node["choices"])
|
||||
for value in node.values():
|
||||
walk(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
walk(value)
|
||||
|
||||
walk(definition)
|
||||
assert not [label for label in labels if "기슭막이" in label and label != "기슭막이"]
|
||||
assert ["기슭막이", "집수정"] in choices and ["기슭막이"] in choices
|
||||
@@ -0,0 +1,92 @@
|
||||
"""107 세월교 — 유입 · 유출 따로 · 원지반 제약 삭제 · 유입 날개벽 수평 여유.
|
||||
|
||||
화면 TS 를 그 자리에서 돌린 값(`helper_107_ford_free.cjs`) · 픽스처 = 73-4 세월교 173.09 · 박스 352.14.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
HELPER = Path(__file__).parent / "helper_107_ford_free.cjs"
|
||||
NODE_MODULES = ROOT / "config" / "node_modules"
|
||||
|
||||
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_세월교_박스를_고르면_같은_측점_벽_고름을_풂(out: dict) -> None:
|
||||
assert out["released"] == [173.09, 352.14]
|
||||
|
||||
|
||||
def test_벽을_고르면_세월교_박스_고름을_풂() -> None:
|
||||
station = _read("B06_Section/B06_Section_UI_Page_Station_Controls.ts")
|
||||
assert (
|
||||
"if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);"
|
||||
in station
|
||||
)
|
||||
assert (
|
||||
"if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);"
|
||||
in station
|
||||
)
|
||||
view = _read("B06_Section/B06_Section_UI_Cross_View.ts")
|
||||
# 카드 안 — 벽을 고르면 본체 창을 닫고 · 본체를 고르면 벽 창을 닫음
|
||||
assert "bodies.clearSelection(); // 세월교 · 박스 고름을 풂" in view
|
||||
assert "if (activeRevet !== null) toggleRevet(activeRevet);" in view
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", ["guards", "inletWing"])
|
||||
def test_세월교_원지반을_바꿔도_바닥_그대로(out: dict, variant: str) -> None:
|
||||
"""107-2 — 바닥 = 노면 − (관경 + 최소 토피) · 하상 −2 m · 전체 +0.5 / −1 m 사본도 같은 모양."""
|
||||
runs = out["ford"][variant]
|
||||
for name, shape in runs.items():
|
||||
assert shape == runs["base"], name
|
||||
base = runs["base"]
|
||||
assert all(side["depth"] == pytest.approx(base["pipeCover"]) for side in base["sides"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", ["guards", "bare"])
|
||||
def test_박스_원지반을_바꿔도_본체_끝_날개_흙막이_그대로(out: dict, variant: str) -> None:
|
||||
runs = out["box"][variant]
|
||||
for name, shape in runs.items():
|
||||
assert shape == runs["base"], name
|
||||
if variant == "bare":
|
||||
assert all(side["apron"] for side in runs["base"]["sides"])
|
||||
else:
|
||||
assert len(runs["base"]["walls"]) == 2
|
||||
|
||||
|
||||
def test_유입_날개벽_끝이_묻히면_수평_여유_뒤_절토(out: dict) -> None:
|
||||
"""107-3 — 흙막이 수평공간과 같은 칸(빈 값 = 1 m) · 유출 날개벽은 수평 구간 없음."""
|
||||
cuts = out["wingCut"]
|
||||
for case, berm in (("auto", 1.0), ("two", 2.0)):
|
||||
(x0, z0), (x1, z1) = cuts[case]["inlet"][:2]
|
||||
assert z1 == pytest.approx(z0) and abs(x1 - x0) == pytest.approx(berm), case
|
||||
(x0, z0), (x1, z1) = cuts["zero"]["inlet"][:2]
|
||||
assert z1 > z0 + 0.1 # 0 = 바로 절토
|
||||
assert cuts["auto"]["outlet"][1][1] > cuts["auto"]["outlet"][0][1] + 0.1
|
||||
|
||||
|
||||
def test_날개벽_수평_여유_칸은_흙막이_수평공간과_같은_키() -> None:
|
||||
ford = _read("A00_Common/design/design_structure_ford.ts")
|
||||
assert '...bodyGuardItems(id, "본체 끝 높이(자동)", true),' in ford
|
||||
guard = _read("A00_Common/design/design_structure_guard.ts")
|
||||
assert "map: wingBerm ? { 흙막이: branch, 날개벽: branch } : { 흙막이: branch }," in guard
|
||||
@@ -0,0 +1,124 @@
|
||||
"""PLAN 108 — 횡단도 안 조정창 끌기 두 축 · 패널 걸음 맞춤 · 미리 보기 = 결과 · BOX 끝 판정면.
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Cross_Panel_Base.ts`(`snapAxisDrag` · `bindDragHandle`) ·
|
||||
`_Cross_Structure_Panel.ts` · `_Cross_View_Structure.ts`(벽 · 집수정 축 · 한계) · `_Cross_Box_Panel.ts` ·
|
||||
`_Cross_Box.ts`(`boxHitBands`). 실제 TS 를 `helper_108_drag_box_hit.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_108_drag_box_hit.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
|
||||
def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None:
|
||||
"""배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수."""
|
||||
case = out["wall"][side]
|
||||
assert case["update"] == {"role": "outlet", "x": 1, "d": 4}
|
||||
assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"]
|
||||
assert "▼ 2.0m" in case["snap"]["text"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("side", ["diag1", "diag-1"])
|
||||
def test_미리보기_자리는_놓은_뒤_자리와_같다(out, side) -> None:
|
||||
"""미리 보기 이동 = `placePipeWall` 로 다시 푼 벽 자리 이동(화면 왼쪽 · 아래)."""
|
||||
case = out["wall"][side]
|
||||
assert case["snap"]["leftM"] == case["result"]["leftM"]
|
||||
assert case["snap"]["downM"] == case["result"]["downM"]
|
||||
|
||||
|
||||
def test_한계면_가장_가까운_허용_자리(out) -> None:
|
||||
"""사면 위로 · 안쪽으로 넘겨 끌면 d 0 · x 0 에서 멈춤 — 원래 자리로 돌아가지 않음."""
|
||||
case = out["wall"]["limit"]
|
||||
assert case["update"] == {"role": "outlet", "x": 0, "d": 0}
|
||||
assert case["snap"] == {"leftM": -1, "downM": -0.417, "text": "▶ 0.5m · ▲ 0.5m"}
|
||||
|
||||
|
||||
def test_흙막이는_안쪽도_되고_10m_에서_멈춘다(out) -> None:
|
||||
assert out["wall"]["guard"]["update"] == {"role": "outlet", "x": -10, "d": 0}
|
||||
assert out["wall"]["guard"]["snap"]["leftM"] == -10
|
||||
|
||||
|
||||
def test_다단_벽은_순수_수직_선반이_따라_나감(out) -> None:
|
||||
"""다단 ▲▼ = 수직 하강 · x ≥ 1.2·d(선반) — 미리 보기도 그 자리."""
|
||||
case = out["wall"]["extra"]
|
||||
assert case["update"] == {"role": "extra0", "x": 1.2, "d": 1}
|
||||
assert case["snap"]["leftM"] == 1.2 and case["snap"]["downM"] == 1
|
||||
|
||||
|
||||
def test_집수정은_사면_축_0_1_걸음(out) -> None:
|
||||
case = out["wall"]["basin"]
|
||||
assert case["update"]["basin"]["lateralM"] == 0.9
|
||||
assert case["update"]["basin"]["slopeM"] == 0.3
|
||||
assert case["snap"] == {"leftM": -0.9, "downM": 0.25, "text": "▶ 0.6m · ▼ 0.3m"}
|
||||
|
||||
|
||||
def test_걸음_반도_안_끌면_반영_없음(out) -> None:
|
||||
assert out["wall"]["basinNone"] == {
|
||||
"snap": {"leftM": 0, "downM": 0, "text": ""},
|
||||
"update": None,
|
||||
}
|
||||
|
||||
|
||||
def test_BOX_끌기는_길이_표고_함께(out) -> None:
|
||||
assert out["box"]["left"]["move"] == {"role": "left", "screen": 0.5, "rise": -0.3}
|
||||
assert out["box"]["left"]["snap"]["leftM"] == 0.5
|
||||
assert out["box"]["right"]["move"] == {"role": "right", "screen": -0.5, "rise": 0.2}
|
||||
|
||||
|
||||
def test_BOX_한계_길이_0_표고_10(out) -> None:
|
||||
"""안으로 줄이면 길이 0 에서 · 표고는 +10 m 에서 멈춤."""
|
||||
assert out["box"]["shorten"]["move"] == {"role": "left", "screen": -0.2, "rise": 0.1}
|
||||
|
||||
|
||||
def test_세월교_끌기는_좌우_사면_함께(out) -> None:
|
||||
"""사면 d 0.3 · 좌우 0.2(0.1 걸음) — 미리 보기 = 좌 0.2 + 0.3 · 아래 0.3/1.2."""
|
||||
case = out["ford"]["diag"]
|
||||
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0.3}
|
||||
assert case["snap"] == {"leftM": 0.5, "downM": 0.25, "text": "◀ 0.2m · ▼ 0.3m"}
|
||||
|
||||
|
||||
def test_세월교_노견_안쪽은_0_에서_멈춤(out) -> None:
|
||||
"""좌우 0.2 에서 안쪽으로 0.9 끌어도 0 까지만(가장 가까운 허용 자리)."""
|
||||
case = out["ford"]["inward"]
|
||||
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0}
|
||||
assert case["snap"]["leftM"] == 0.2
|
||||
|
||||
|
||||
def test_조정창은_제목과_단추만(out) -> None:
|
||||
"""머리줄 = 제목 · 닫기(집수정 ↺ 없음) · BOX 오버레이 읽기 줄 = 끄는 중 미리 보기 하나."""
|
||||
assert out["wall"]["diag1"]["overlayHead"] == [
|
||||
"b06-structure-panel__title",
|
||||
"b06-structure-panel__btn b06-structure-panel__close",
|
||||
]
|
||||
assert out["box"]["left"]["value"] == ["b06-structure-panel__drag"]
|
||||
assert out["ford"]["diag"]["value"] == ["b06-structure-panel__drag"]
|
||||
|
||||
|
||||
def test_BOX_끝_판정면은_본체로_안_넘어감(out) -> None:
|
||||
"""본체 = 두 구체 끝 사이 · 끝 = 구체 끝 ~ 에이프런 바깥 · 날개벽 없는 끝 = 바깥 0.3 m 띠."""
|
||||
hit = out["hit"]
|
||||
assert hit["body"]["offsets"] == [-4, 4]
|
||||
assert hit["left"]["offsets"] == [4, 6]
|
||||
assert hit["right"]["offsets"] == [-4.3, -4]
|
||||
for role in ("body", "left", "right"):
|
||||
assert hit[role]["elevations"] == [96.5, 99]
|
||||
@@ -0,0 +1,131 @@
|
||||
"""110 — B08 수리 판정 하상경사 ← B06 횡단 경사.
|
||||
|
||||
관 = 관 바닥 두 끝 경사(`CulvertLayout.pipe.slopePct`) · 박스 = 박스 두 끝 원지반 경사(본체는 늘 수평) ·
|
||||
관 길이와 같은 길(`STRUCTURE_ROW_KEYS` → 서버 Node → `_AREA_KEYS` → 정본) · B08 은 연결값(linked)으로
|
||||
실음(저장된 측점 null 이 덮지 않게) · 0 % 이하는 빈칸(손 입력) · 세월교 · 물넘이는 장 기본값 그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Hydro_Defaults import ( # noqa: E402
|
||||
DEFAULTS,
|
||||
SHEET_DEFAULTS,
|
||||
station_rows,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import PipePoint # noqa: E402
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
NODE_MODULES = ROOT / "config" / "node_modules"
|
||||
needs_node = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
|
||||
|
||||
BASINS = [
|
||||
{"kind": "detail_basin", "chainage_m": c, "area_m2": 10717.0, "flow_length_m": 163.9,
|
||||
"relief_m": 120.0, "design_flow_m3s": 1.0, "tc_minutes": 5.0}
|
||||
for c in (85.05, 173.09, 300.0, 400.0, 500.0)
|
||||
] # fmt: skip
|
||||
POINTS = [
|
||||
PipePoint(chainage_m=85.052, options={"pipe_diameter_mm": "1000"}),
|
||||
PipePoint(chainage_m=173.09, facility="ford_bridge", options={"ford_width_m": 10}),
|
||||
PipePoint(chainage_m=300.004, facility="box_culvert",
|
||||
options={"body_width_m": 2.0, "body_height_m": 1.5}),
|
||||
PipePoint(chainage_m=400.0, options={"pipe_diameter_mm": 800}),
|
||||
PipePoint(chainage_m=500.0, options={"pipe_diameter_mm": 800}),
|
||||
] # fmt: skip
|
||||
DESIGNS = [
|
||||
# 관 자리 85.052 ↔ 횡단 85.0(스냅 · 0.5 m 안)
|
||||
{"chainage_m": 85.0, "design": {"pipe_slope_pct": 5.0, "pipe_length_m": 9}},
|
||||
# 세월교에 경사 키가 있어도 안 이음(장 기본값)
|
||||
{"chainage_m": 173.09, "design": {"pipe_slope_pct": 4.0, "box_ground_slope_pct": 4.0}},
|
||||
{"chainage_m": 300.0, "design": {"box_ground_slope_pct": 2.0}},
|
||||
{"chainage_m": 400.0, "design": {"pipe_slope_pct": 0.0}}, # 수평 관 → 빈칸
|
||||
{"chainage_m": 501.0, "design": {"pipe_slope_pct": 3.0}}, # 1 m 떨어짐 → 짝 없음
|
||||
]
|
||||
|
||||
|
||||
def _rows() -> dict[float, dict]:
|
||||
return {r["chainage_m"]: r for r in station_rows(POINTS, BASINS, DESIGNS)}
|
||||
|
||||
|
||||
def test_관_경사가_하상경사_칸으로() -> None:
|
||||
assert _rows()[85.052]["linked"]["slope_denominator"] == 20.0 # 5 % → 1/20
|
||||
|
||||
|
||||
def test_박스는_원지반_경사() -> None:
|
||||
assert _rows()[300.004]["linked"]["slope_denominator"] == 50.0 # 2 % → 1/50
|
||||
|
||||
|
||||
def test_0_퍼센트_이하와_짝_없음은_빈칸() -> None:
|
||||
rows = _rows()
|
||||
assert "slope_denominator" not in rows[400.0]["linked"]
|
||||
assert "slope_denominator" not in rows[500.0]["linked"]
|
||||
assert DEFAULTS["slope_denominator"] is None # 손 입력 자리
|
||||
|
||||
|
||||
def test_세월교는_장_기본값_그대로() -> None:
|
||||
assert "slope_denominator" not in _rows()[173.09]["linked"]
|
||||
assert SHEET_DEFAULTS["ford"]["slope_denominator"] > 0
|
||||
|
||||
|
||||
def test_연결값으로_판정이_나옴() -> None:
|
||||
row = _rows()[85.052]
|
||||
out = compute({**DEFAULTS, **row["linked"], "r24_mm": 231.56, "sheet": "pipe"})
|
||||
assert out["ok"] in (True, False)
|
||||
assert out["slope_bed"] == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_저장된_측점_null_이_연결값을_덮지_않음() -> None:
|
||||
"""화면은 저장본 · 초안의 null 을 연결값 칸에서 건너뜀 — 연결값으로 실어야 저장된 측점도 바뀜."""
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Hydro.ts").read_text(encoding="utf-8")
|
||||
assert "v != null || !(k in linkedOf(station))" in ui
|
||||
assert "...station.linked," in ui
|
||||
|
||||
|
||||
def test_패치가_받는다() -> None:
|
||||
patch = CrossSectionPatch(chainage_m=1, pipe_slope_pct=0.0, box_ground_slope_pct=-3.5)
|
||||
assert (patch.pipe_slope_pct, patch.box_ground_slope_pct) == (0.0, -3.5)
|
||||
|
||||
|
||||
def _server_rows(sections: list[dict]) -> dict[float, dict]:
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import _NPM_SCRIPT, BUNDLE
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
|
||||
output = run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": {"cross_sections": sections}})
|
||||
assert output, "서버 Node 번들 실행 실패"
|
||||
return {round(r["chainage_m"], 3): r for r in output["areas"]}
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_서버가_관_경사를_관_길이와_같이_냄() -> None:
|
||||
sections = json.loads((FIXTURES / "b07_cross_overlay_sections.json").read_text("utf-8"))
|
||||
row = _server_rows(sections["cross_sections"])[85.052]
|
||||
assert row["pipe_length_m"] > 0
|
||||
# 관 바닥 두 끝 경사 — 유입이 유출보다 낮게 그려지지 않음(역경사는 수평으로 클램프)
|
||||
assert row["pipe_slope_pct"] >= 0
|
||||
|
||||
|
||||
@needs_node
|
||||
def test_서버가_박스_원지반_경사를_주인_측점에만() -> None:
|
||||
data = json.loads((FIXTURES / "b06_73_4_body_guard_sections.json").read_text("utf-8"))
|
||||
sections = data["cross_sections"]
|
||||
box_at = next(s for s in sections if s.get("box"))
|
||||
# 옆 측점에 같은 박스가 걸쳐도(폭 안 연동) 주인 측점에만 실림
|
||||
neighbour = next(s for s in sections if s["chainage_m"] == 340)
|
||||
neighbour["box"] = copy.deepcopy(box_at["box"])
|
||||
rows = _server_rows(sections)
|
||||
assert isinstance(rows[352.14]["box_ground_slope_pct"], float)
|
||||
assert "box_ground_slope_pct" not in rows[340]
|
||||
# 세월교 · 박스 흙막이는 관이 아님 — 관 경사 안 실음
|
||||
assert all("pipe_slope_pct" not in r for r in rows.values())
|
||||
@@ -56,6 +56,9 @@ def test_유입_유출은_구조와_흙막이_칸만(out):
|
||||
for spec in ("ford", "box"):
|
||||
for side in ("inlet", "outlet"):
|
||||
want = GUARD if side == "inlet" else GUARD[:-1]
|
||||
if spec == "ford" and side == "inlet":
|
||||
# 세월교 유입 날개벽도 같은 칸(수평 여유 · 107-3)
|
||||
want = [*GUARD[:-1], ["berm", ["흙막이", "날개벽"], "1"]]
|
||||
assert out[spec][side] == want, (spec, side)
|
||||
assert [i[0] for i in out[spec]["soil_add"]] == [
|
||||
"form",
|
||||
@@ -164,12 +167,9 @@ def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼():
|
||||
assert groups(type_id, "", None) == []
|
||||
|
||||
|
||||
def test_B06_세월교_박스_칸은_측벽_높이_줄만_받음():
|
||||
def test_B06_세월교_박스_칸은_받는_줄_없음():
|
||||
"""107-3 — 측벽 높이 줄 지움."""
|
||||
dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
|
||||
assert (
|
||||
"slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW"
|
||||
in dock
|
||||
)
|
||||
assert 'WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height"' in dock
|
||||
assert "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null" in dock
|
||||
lists = (ROOT / "B05_Profile/B05_Profile_UI_Structures_List.ts").read_text(encoding="utf-8")
|
||||
assert "return isNone(c, state[c.id][0])" in lists
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -74,3 +75,11 @@ def test_박스도_흙막이를_밀면_본체가_따라감(fit: dict) -> None:
|
||||
assert fit["boxInletPlus1"]["bodyLengthM"] == pytest.approx(
|
||||
fit["boxAuto"]["bodyLengthM"] + 1, abs=1e-3
|
||||
)
|
||||
|
||||
|
||||
def test_유입_측벽을_옮겨도_유출_흙막이_자리_그대로(fit: dict) -> None:
|
||||
"""107-1 — 관 1 m 맞춤은 기본 자리 기준 · 사용자 이동이 반대쪽 흙막이를 안 밈 · L 은 올림."""
|
||||
backs = {case["back"] for case in fit["inletMoves"] + fit["inletSlopes"]}
|
||||
assert len(backs) == 1, fit["inletMoves"]
|
||||
for case in fit["inletMoves"] + fit["inletSlopes"]:
|
||||
assert case["label"].endswith(f"L{math.ceil(case['lengthM'] - 1e-6)}m")
|
||||
|
||||
@@ -29,9 +29,9 @@ def fit() -> dict:
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_세월교_바닥_자동은_하상을_따라감(fit: dict) -> None:
|
||||
for before, after in zip(fit["fordAuto"], fit["fordAutoBedDown"]):
|
||||
assert after["slabTop"] < before["slabTop"] - 1.0
|
||||
def test_세월교_바닥_기본도_원지반을_바꿔도_그대로(fit: dict) -> None:
|
||||
"""107-2 — 기본 바닥 = 노면 − (관경 + 토피) · 하상을 안 따라감(옛 91-2 「자동은 하상」 뒤집음)."""
|
||||
assert fit["fordAuto"] == fit["fordAutoBedDown"]
|
||||
|
||||
|
||||
def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict) -> None:
|
||||
@@ -41,9 +41,11 @@ def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict)
|
||||
assert side["wallBottom"] == pytest.approx(side["slabTop"] - 0.3, abs=1e-3)
|
||||
|
||||
|
||||
def test_세월교_박스_칸은_측벽_높이_줄을_받음() -> None:
|
||||
def test_세월교_박스_칸은_측벽_높이_줄_없음() -> None:
|
||||
"""107-3 — 「측벽 높이(자동)」 줄 지움(유입 · 유출)."""
|
||||
dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8")
|
||||
assert "sideSlots?.wingOutlet) return WALL_HEIGHT_ROW" in dock
|
||||
assert "sideSlots?.wingOutlet) return null" in dock
|
||||
assert "wall-height" not in dock
|
||||
|
||||
|
||||
def test_다단_자동_자리는_원지반과_무관_사면_1m(fit: dict) -> None:
|
||||
|
||||
@@ -68,6 +68,7 @@ def test_측점마다_연결값() -> None:
|
||||
|
||||
|
||||
def test_잇지_않는_칸() -> None:
|
||||
# 하상경사는 B06 횡단 경사가 있을 때만 이음(110 · test_110_b08_bed_slope) — 설계 없이는 빈칸
|
||||
for row in station_rows(POINTS, BASINS):
|
||||
assert not {"slope_denominator", "pipe_kind", "n"} & set(row["linked"])
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_폼과_같은_값은_사본_줄로_만들지_않는다(out) -> None:
|
||||
assert out["dock"]["outlet"] == ["options", "readout", "tier"]
|
||||
# 집수정 내공 줄은 화면에서 숨김(54-40 · 값 · 그림 그대로)
|
||||
assert out["dock"]["basin"] == ["options", "readout"]
|
||||
assert out["dock"]["ford"] == ["readout", "wall-height"]
|
||||
assert out["dock"]["ford"] == ["readout"] # 측벽 높이 줄 지움(107-3)
|
||||
assert out["dock"]["box"] == ["readout"]
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ def test_사본_줄은_모두_폼_칸으로_옮겨_붙는다() -> None:
|
||||
"tier",
|
||||
"options",
|
||||
"basin-size",
|
||||
"wall-height",
|
||||
"form",
|
||||
"height",
|
||||
"span",
|
||||
|
||||
@@ -207,6 +207,9 @@ def test_ford_box_guard_attached_as_culvert() -> None:
|
||||
assert "culvert" not in sections[100.0]
|
||||
# 유입만 수평공간(89-4) · 도수로/돌붙임 두께 cm → m(90-2)
|
||||
assert ford["inlet"]["revet_berm_m"] == 2 and "revet_berm_m" not in ford["outlet"]
|
||||
# 유입 날개벽 수평 여유 = 같은 칸(107-3) · 안 세운 날개벽엔 안 실림
|
||||
wings = sections[60.0]["ford"]
|
||||
assert wings["wing_in"]["berm_m"] == 2 and "berm_m" not in wings["wing_out"]
|
||||
assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3}
|
||||
assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45}
|
||||
assert "chute" not in sections[40.0]["culvert"]
|
||||
|
||||
@@ -40,6 +40,9 @@ _COMPUTED_FIELDS = {
|
||||
# 다리**(`STRUCTURE_ROW_KEYS` → `B06_Section_Server_Calc_Node` →
|
||||
# `recompute_server_side`)로 서버가 다시 내므로 재계산에서 안 사라진다.
|
||||
"pipe_length_m",
|
||||
# B08 하상경사 원천(110) — 관 경사 · 박스 원지반 경사(%) · 관 길이와 같은 다리.
|
||||
"pipe_slope_pct",
|
||||
"box_ground_slope_pct",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_면적을_못_내는_측점에서도_길이는_낸다():
|
||||
"""설계선이 모자라 폐회로 면적을 못 내는 자리에도 **관은 서 있다** — 수량은 그 길이가 필요하다."""
|
||||
source = _LAYOUTS.read_text(encoding="utf-8")
|
||||
assert "return pipeRow;" in source, "면적 실패 시 null 로 떨어지면 관 길이까지 사라진다"
|
||||
assert "layouts.culvert?.pipe?.lengthM" in source
|
||||
assert "layouts.culvert?.pipe" in source and "pipe.lengthM" in source
|
||||
|
||||
|
||||
def test_이름이_한_벌이다():
|
||||
@@ -99,8 +99,8 @@ def test_소유_측점에만_싣는다():
|
||||
**하나만 고르는 것**은 그대로라 두 번 세지 않는다.
|
||||
"""
|
||||
layouts = _LAYOUTS.read_text(encoding="utf-8")
|
||||
assert "pipeOwnerChainage" in layouts, "소유 측점을 안 가리고 있다"
|
||||
assert "section.culvert?.chainage_m" in layouts, "관이 놓인 자리를 안 읽고 있다"
|
||||
assert "ownerChainage" in layouts, "소유 측점을 안 가리고 있다"
|
||||
assert "isOwner(section.culvert.chainage_m)" in layouts, "관이 놓인 자리를 안 읽고 있다"
|
||||
py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선
|
||||
② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번
|
||||
③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침
|
||||
④ 상자 밑 선(등고선 · 계류 · 필지 · 읍면동 · 시군구)은 잘려 상자 안 선 0 (109-4)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -11,6 +12,7 @@ from pathlib import Path
|
||||
from shapely.geometry import LineString, Point
|
||||
from shapely.geometry import box as box_polygon
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import clip_lines
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
@@ -18,9 +20,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import (
|
||||
layer_id,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import (
|
||||
LABEL_CLIPPED,
|
||||
PARCEL_FONT,
|
||||
ROUTE_CLEAR_M,
|
||||
clip_parcels,
|
||||
label_rects,
|
||||
place_labels,
|
||||
)
|
||||
|
||||
@@ -161,3 +165,44 @@ def _polyline(entity: dict) -> LineString:
|
||||
c["shapeData"]["endPoint"] for c in children
|
||||
]
|
||||
return LineString([(p["x"], p["y"]) for p in points])
|
||||
|
||||
|
||||
def test_lines_under_label_boxes_are_cut(tmp_path: Path) -> None:
|
||||
features = [
|
||||
(
|
||||
{
|
||||
"pnu": f"P{i}{j}",
|
||||
"jibun": f"{i}{j}-1",
|
||||
"jimok": "임야",
|
||||
"parea": 9999,
|
||||
"owner_nm": "홍길동",
|
||||
},
|
||||
_polygon(_square(i * 100.0, j * 100.0, 100.0)),
|
||||
)
|
||||
for i in range(4)
|
||||
for j in range(3)
|
||||
]
|
||||
parcels = clip_parcels(_geojson(tmp_path, features), "EPSG:4326", BOX)
|
||||
route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)]
|
||||
items = place_labels(parcels, route, BOX)
|
||||
rects = label_rects(items)
|
||||
assert PARCEL_FONT == 3.6 and rects == [_rect(item) for item in items] # 그리는 상자와 같음
|
||||
# 도곽 전체를 촘촘히 가로지르는 배경 선 — 상자마다 반드시 지나감
|
||||
across = [[[0.0, y], [400.0, y]] for y in range(1, 300, 2)]
|
||||
pieces = {
|
||||
"contour": {"lines": [list(line) for line in across]},
|
||||
"stream": {"lines": [[[x, 0.0], [x, 300.0]] for x in range(1, 400, 2)]},
|
||||
"parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]},
|
||||
"emd": {"lines": [list(line) for line in across]},
|
||||
"sgg": {"lines": [list(line) for line in across]},
|
||||
"centerline": {"lines": [[list(p) for p in route]]},
|
||||
}
|
||||
clip_lines(pieces, rects, LABEL_CLIPPED)
|
||||
inner = [box_polygon(*rect).buffer(-1e-3) for rect in rects]
|
||||
for piece in LABEL_CLIPPED:
|
||||
for points in pieces[piece]["lines"]:
|
||||
line = LineString(points)
|
||||
assert all(line.intersection(area).length < 1e-6 for area in inner), piece
|
||||
# 상자 밖은 남음 · 노선은 그대로
|
||||
assert sum(LineString(p).length for p in pieces["contour"]["lines"]) > 0.5 * 150 * 400
|
||||
assert pieces["centerline"]["lines"] == [[list(p) for p in route]]
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호
|
||||
⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층
|
||||
⑦ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층
|
||||
⑧ 배수유역도 종류(109장) — 양식 watershed · 한 장 목록 · 지우기 · 다시 만들기 · IP 표 두 줄 차례 ·
|
||||
용지도 상자 안 선 0
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -19,6 +21,7 @@ import pytest
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import (
|
||||
_place,
|
||||
curve_values,
|
||||
dms,
|
||||
ip_blocks,
|
||||
@@ -38,6 +41,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
add_kind,
|
||||
delete_kind,
|
||||
list_kinds,
|
||||
plan_kind_items,
|
||||
read_marks,
|
||||
slope_records,
|
||||
update_kind,
|
||||
@@ -139,6 +143,7 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None:
|
||||
"대피소",
|
||||
"라이다",
|
||||
"용지도",
|
||||
"배수유역도",
|
||||
]
|
||||
added = add_kind(root, "내 평면도", ["centerline", "station", "nope"])
|
||||
assert added["pieces"] == ["centerline", "station"]
|
||||
@@ -148,14 +153,28 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None:
|
||||
add_kind(root, "빈 것", [])
|
||||
delete_kind(root, "terrain") # 기본 넷도 지움
|
||||
again = list_kinds(root) # 다시 읽어 같음
|
||||
assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "용지도", "내 평면도"]
|
||||
assert [k["name"] for k in again] == [
|
||||
"노선배치도",
|
||||
"대피소",
|
||||
"라이다",
|
||||
"용지도",
|
||||
"배수유역도",
|
||||
"내 평면도",
|
||||
]
|
||||
assert again[-1]["pieces"] == ["centerline", "station"]
|
||||
with pytest.raises(FileNotFoundError):
|
||||
delete_kind(root, "terrain")
|
||||
for kind in list(again):
|
||||
delete_kind(root, kind["id"])
|
||||
assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음)
|
||||
assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar", "landuse"}
|
||||
assert {k["id"] for k in DEFAULT_KINDS} == {
|
||||
"terrain",
|
||||
"route",
|
||||
"layout",
|
||||
"lidar",
|
||||
"landuse",
|
||||
"watershed",
|
||||
}
|
||||
|
||||
|
||||
def test_refuge_marks_same_spot(tmp_path: Path) -> None:
|
||||
@@ -202,7 +221,7 @@ def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None:
|
||||
outline = {"road_edge", "fill_outline", "cut_outline"}
|
||||
# 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로)
|
||||
for kind in DEFAULT_KINDS:
|
||||
if kind["id"] != "landuse":
|
||||
if kind["id"] not in ("landuse", "watershed"):
|
||||
assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"]
|
||||
folder = _kinds_dir(root)
|
||||
folder.mkdir(parents=True)
|
||||
@@ -227,6 +246,7 @@ def test_update_kind_default_and_release_confirmed(tmp_path: Path) -> None:
|
||||
"id": "terrain",
|
||||
"name": "지형2",
|
||||
"pieces": ["contour", "centerline"],
|
||||
"layout": "plan",
|
||||
}
|
||||
assert [k["id"] for k in list_kinds(root)][0] == "terrain" # 차례 그대로
|
||||
drawings = _read_manifest(root)["drawings"]
|
||||
@@ -315,9 +335,9 @@ def test_landuse_kind_added_once_to_old_folder(tmp_path: Path) -> None:
|
||||
json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8"
|
||||
)
|
||||
kinds = list_kinds(root)
|
||||
assert [k["id"] for k in kinds][-1] == "landuse"
|
||||
assert kinds[-1]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"]
|
||||
assert len(list_kinds(root)) == 5 # 두 번 읽어도 한 번
|
||||
assert [k["id"] for k in kinds][-2:] == ["landuse", "watershed"] # 늘어난 기본 종류 둘
|
||||
assert kinds[-2]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"]
|
||||
assert len(list_kinds(root)) == 6 # 두 번 읽어도 한 번
|
||||
delete_kind(root, "landuse")
|
||||
assert "landuse" not in [k["id"] for k in list_kinds(root)] # 지운 것은 안 살아남
|
||||
|
||||
@@ -344,3 +364,79 @@ def test_landuse_pieces_become_layers() -> None:
|
||||
if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text"
|
||||
]
|
||||
assert labels == ["1-1", "임야", "국"]
|
||||
|
||||
|
||||
def test_watershed_kind_layout_one_sheet(tmp_path: Path) -> None:
|
||||
"""배수유역도 = 기본 종류 · 양식 watershed · 조각 없음 · 장이 여럿이어도 목록은 한 장."""
|
||||
root = tmp_path / "project"
|
||||
watershed = next(k for k in list_kinds(root) if k["id"] == "watershed")
|
||||
assert watershed == {
|
||||
"id": "watershed",
|
||||
"name": "배수유역도",
|
||||
"pieces": [],
|
||||
"layout": "watershed",
|
||||
}
|
||||
# 측점 0 ~ 2,000 m — 1/1,200 평면도는 여러 장 · 유역도는 한 장
|
||||
stations = [
|
||||
{"chainage_m": float(c), "center_x": 0.0, "center_y": float(c)} for c in range(0, 2001, 20)
|
||||
]
|
||||
items = plan_kind_items(root, {"stations": stations})
|
||||
terrain = [item for item in items if item["plan_kind"] == "terrain"]
|
||||
mine = [item for item in items if item["plan_kind"] == "watershed"]
|
||||
assert len(terrain) > 1
|
||||
assert mine == [
|
||||
{
|
||||
"id": "plan_watershed",
|
||||
"label": "배수유역도",
|
||||
"plan_kind": "watershed",
|
||||
"plan_kind_name": "배수유역도",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_watershed_kind_delete_and_add_again(tmp_path: Path) -> None:
|
||||
"""× 로 지우면 안 살아남 · 「새 종류」 양식 유역도로 다시 만듦 · 업데이트는 양식 그대로."""
|
||||
root = tmp_path / "project"
|
||||
delete_kind(root, "watershed")
|
||||
assert "watershed" not in [k["id"] for k in list_kinds(root)]
|
||||
assert "watershed" not in [k["id"] for k in list_kinds(root)] # 다시 읽어도 안 살아남
|
||||
added = add_kind(root, "배수유역도", ["contour"], "watershed") # 조각은 버림
|
||||
assert added["layout"] == "watershed" and added["pieces"] == []
|
||||
again = next(k for k in list_kinds(root) if k["id"] == added["id"])
|
||||
assert again == added
|
||||
assert any(
|
||||
item["id"] == f"plan_{added['id']}" for item in plan_kind_items(root, {"stations": []})
|
||||
)
|
||||
update_kind(root, added["id"], "유역도2", []) # 유역도 양식은 조각 없이 업데이트
|
||||
assert next(k for k in list_kinds(root) if k["id"] == added["id"])["layout"] == "watershed"
|
||||
with pytest.raises(ValueError):
|
||||
add_kind(root, "이상한 양식", ["contour"], "nope")
|
||||
|
||||
|
||||
def test_watershed_kind_added_once_to_folder_with_landuse(tmp_path: Path) -> None:
|
||||
"""용지도 표시가 있는 옛 폴더 — 배수유역도만 한 번 더해짐 · 지우면 안 살아남."""
|
||||
root = tmp_path / "project"
|
||||
folder = _kinds_dir(root)
|
||||
folder.mkdir(parents=True)
|
||||
for order, kind in enumerate(DEFAULT_KINDS[:5]):
|
||||
(folder / f"{kind['id']}.json").write_text(
|
||||
json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8"
|
||||
)
|
||||
(folder / "_landuse_added").touch()
|
||||
assert [k["id"] for k in list_kinds(root)][-1] == "watershed"
|
||||
delete_kind(root, "watershed")
|
||||
assert [k["id"] for k in list_kinds(root)][-1] == "landuse"
|
||||
|
||||
|
||||
def test_ip_table_two_lines_left_to_right_top_to_bottom() -> None:
|
||||
"""한 줄 셋까지 들어가는 폭 · 묶음 넷 — 첫 줄(1 · 2 · 3) 맨 위 왼 → 오 · 넷째는 아랫줄 왼쪽."""
|
||||
blocks = [{"name": str(i), "ia": 1.0} for i in range(1, 5)]
|
||||
block_w = 7.0 + 6.0 + 23.0
|
||||
_place(blocks, 2 * 5.0 + 3 * block_w + 1.0)
|
||||
origins = [b["origin"] for b in blocks]
|
||||
tops = [o[1] for o in origins]
|
||||
assert tops[0] == tops[1] == tops[2] > tops[3] # 첫 줄이 위
|
||||
assert origins[0][0] < origins[1][0] < origins[2][0] # 왼 → 오
|
||||
assert origins[3][0] == origins[0][0] # 다음 줄은 다시 왼쪽
|
||||
assert tops[3] == 5.0 + 8 * 5.0 # 마지막 줄이 도곽 왼쪽 아래 기준(옛 한 줄 자리와 같음)
|
||||
assert tops[0] - tops[3] == 8 * 5.0 + 3.0 # 줄 간격 그대로
|
||||
|
||||
@@ -118,6 +118,39 @@ def test_일위대가_합계_줄_번호_칸(tmp_path: Path, monkeypatch: pytest.
|
||||
assert got["d"]["일위대가번"] == "SU-000004" and got["d"]["도번"] == ""
|
||||
|
||||
|
||||
def test_일위대가_합계_상태_넷(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""101-1 — 합계 칸 색: 붉음(기준 안 나온 돈 줄 · 단가 없는 자재) > 노랑(수량 없는 줄 · 자재) >
|
||||
초록(모두 반영) · 돈 아닌 로직 줄은 판정에서 뺌 · 담은 것 없음 = 상태 없음."""
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
(tmp_path / "labor").mkdir()
|
||||
money = {"기준": {"계": 100}, "까닭": None}
|
||||
docs = {
|
||||
"ok": {"줄": [{**money, "수량": 2}, {"결과": 3, "기준": None, "까닭": None}], "자재": [
|
||||
{"기준단가": 5, "수량": 1}
|
||||
]},
|
||||
"short": {"줄": [{**money, "수량": 2}, {**money, "수량": None}], "자재": []},
|
||||
"error": {"줄": [{**money, "수량": None}, {"기준": None, "까닭": "식 오류"}], "자재": [
|
||||
{"기준단가": None, "까닭": "단가 없음", "수량": None},
|
||||
{"기준단가": 5, "수량": None},
|
||||
]},
|
||||
} # fmt: skip
|
||||
for name, calc in docs.items():
|
||||
doc = {"담은로직": ["x"], "계산값": {**calc, "반영합": {"계": 7}, "기준합": {"계": 9}}}
|
||||
(tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8")
|
||||
empty = {"담은로직": [], "계산값": {"줄": [], "반영합": {"계": 7}}}
|
||||
(tmp_path / "labor" / "empty.json").write_text(json.dumps(empty), encoding="utf-8")
|
||||
got = store.structure_numbers()
|
||||
assert (got["ok"]["합계상태"], got["ok"]["합계까닭"]) == ("ok", "모두 반영")
|
||||
assert (got["short"]["합계상태"], got["short"]["합계까닭"]) == ("short", "수량 없는 줄 1")
|
||||
assert (got["error"]["합계상태"], got["error"]["합계까닭"]) == (
|
||||
"error",
|
||||
"기준 단가 없음 2 · 수량 없는 줄 2",
|
||||
)
|
||||
assert "합계상태" not in got["empty"]
|
||||
|
||||
|
||||
def test_일위대가_합계_줄_표_부품과_연결():
|
||||
sheet = _text(SHEET / "ui_template_sheet.ts")
|
||||
assert "laborSumLabel: opts.laborSumLabel," in sheet
|
||||
@@ -131,6 +164,12 @@ def test_일위대가_합계_줄_표_부품과_연결():
|
||||
assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main
|
||||
assert "laborSumState: (colId: string) => laborSumState(struct.numbers()[colId])" in main
|
||||
assert "(기준)" not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 101-1 글 지움
|
||||
assert "is-sum-${sum.state}" in render and "td.is-sum-error" in _text(
|
||||
SHEET / "ui_template_sheet.css"
|
||||
)
|
||||
assert "markSum(c)" in sheet # 101-2 머리 고르면 합계 칸도 같이
|
||||
assert "onNumbers: () => sheet?.recalc()" in main # 카드 [저장] 뒤 다시 읽기
|
||||
struct = _text(M02 / "M02_MasterTemplete_Structure.ts")
|
||||
assert "fetchStructureNumbers().then(" in struct and "opts.onNumbers?.()" in struct
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""일위대가 로직 자재(PLAN 103-1) — 담은 로직 맨 위 층 재료 줄 · 고름 · 합 이중 계산 없음.
|
||||
|
||||
- 로직 줄 계산값 `재료` = 호표 재료 줄(차례 · 품목 · 단가 · 금액 · 걸림) · 재료 없는 로직은 빈 목록
|
||||
- 고름 `{호표 차례: MT키}` → 그 줄 품목 · 단가 · 로직 재료비가 바뀜(엔진 `choosing`) ·
|
||||
고름 없으면 지금 값
|
||||
- 합 · 알약에 로직 자재 금액을 다시 안 더함 · 저장 뒤 고름 남음 · 틀린 고름 400
|
||||
- 사용자 자재와 같은 품목 = [저장] 400 · 입력에 걸린 고름은 입력이 바뀌면 풂(화면 짝 · Node)
|
||||
- 프로젝트 사본 씨앗 = 문서 고름
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Item_Snapshot as snap
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 MT006697 · 결속선 MT000194 · 에폭시)
|
||||
BORING = "GC000431" # 보링 천공 — 재료 줄 4~8 조건에 {규격} · 9 = MT000008(입력 안 걸림)
|
||||
BARE = "GC000994" # 재료 줄 없는 돈 로직
|
||||
WIRE = "MT000194"
|
||||
NET_ITEM = "MT006697"
|
||||
|
||||
|
||||
def _whole():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def _doc(**extra):
|
||||
return {"열": "rv", "담은로직": [], "자재": [], **extra}
|
||||
|
||||
|
||||
def test_로직_자재는_맨_위_층_재료_줄_재료_없는_로직은_빈_목록():
|
||||
got = labor.run_line(_whole(), NET, {})
|
||||
assert [(m["차례"], m["품목"]["키"]) for m in got["재료"]] == [
|
||||
(3, NET_ITEM),
|
||||
(4, WIRE),
|
||||
(5, "MT007705"),
|
||||
]
|
||||
assert all(m["고름"] is False and m["걸림"] == [] for m in got["재료"])
|
||||
assert labor.run_line(_whole(), BARE, {})["재료"] == []
|
||||
|
||||
|
||||
def test_조건에_든_입력은_걸림():
|
||||
mats = {m["차례"]: m for m in labor.run_line(_whole(), BORING, {})["재료"]}
|
||||
assert all(mats[n]["걸림"] == ["규격"] for n in range(4, 9))
|
||||
assert mats[9]["걸림"] == []
|
||||
|
||||
|
||||
def test_고름이_단가와_로직_재료비를_바꿈_고름_없으면_지금_값():
|
||||
plain = labor.run_line(_whole(), NET, {})
|
||||
again = labor.run_line(_whole(), NET, {}, None, None)
|
||||
assert again["기준"] == plain["기준"] # 고름 없음 = 지금 길 그대로(회귀)
|
||||
picked = labor.run_line(_whole(), NET, {}, None, {"3": WIRE})
|
||||
row = picked["재료"][0]
|
||||
assert (row["품목"]["키"], row["고름"]) == (WIRE, True)
|
||||
assert row["단가"] == picked["재료"][1]["단가"] != plain["재료"][0]["단가"]
|
||||
gap = (plain["재료"][0]["단가"] - row["단가"]) * row["수량"]
|
||||
assert plain["기준"]["재료비"] - picked["기준"]["재료비"] == pytest.approx(gap)
|
||||
assert picked["기준"]["노무비"] == plain["기준"]["노무비"]
|
||||
|
||||
|
||||
def test_합_알약에_로직_자재_금액_다시_안_더함():
|
||||
doc = labor.clean(_doc(담은로직=[{"로직": NET, "수량": {"손": 2}}]))
|
||||
got = labor.calc(doc, {})
|
||||
line = got["줄"][0]
|
||||
assert line["재료"] and got["자재"] == []
|
||||
assert got["기준합"]["계"] == pytest.approx(line["기준"]["계"])
|
||||
assert got["반영합"]["계"] == pytest.approx(line["기준"]["계"] * 2)
|
||||
assert not [p for p in labor.pills(doc) if p["키"].startswith(labor.MATERIAL_HEAD)]
|
||||
|
||||
|
||||
def test_고름_정리_틀리면_400():
|
||||
got = labor.clean(_doc(담은로직=[{"로직": NET, "고름": {3: WIRE}}, {"로직": BARE, "고름": {}}]))
|
||||
assert got["담은로직"][0]["고름"] == {"3": WIRE} and "고름" not in got["담은로직"][1]
|
||||
for bad in ({"0": WIRE}, {"3": "MT999999"}, {"3": "LB000001"}, {"가": WIRE}, ["3"]):
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
labor.clean(_doc(담은로직=[{"로직": NET, "고름": bad}]))
|
||||
assert e.value.status == 400
|
||||
|
||||
|
||||
def test_사용자_자재가_로직_자재와_같으면_저장_400():
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
labor.server(_doc(담은로직=[{"로직": NET}], 자재=[{"코드": WIRE}]))
|
||||
assert e.value.status == 400 and WIRE in str(e.value.detail)
|
||||
# 고른 품목도 같음 — 결속선 줄을 다른 품목으로 바꾸면 사용자 자재로 담을 수 있음
|
||||
with pytest.raises(store.StoreError):
|
||||
labor.server(
|
||||
_doc(담은로직=[{"로직": NET, "고름": {"3": "MT000008"}}], 자재=[{"코드": "MT000008"}])
|
||||
)
|
||||
ok = labor.server(
|
||||
_doc(담은로직=[{"로직": NET, "고름": {"4": NET_ITEM}}], 자재=[{"코드": WIRE}])
|
||||
)
|
||||
assert ok["자재"][0]["코드"] == WIRE
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def system(tmp_path, monkeypatch):
|
||||
write_sample_table(tmp_path)
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
store.create_structure("rv")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_저장_뒤_고름_남고_계산값에_반영(system):
|
||||
have = store.read("labor", "rv")
|
||||
sent = {**have["문서"], "담은로직": [{"로직": NET, "고름": {"3": WIRE}, "수량": None}]}
|
||||
store.write("labor", "rv", have["판"], sent)
|
||||
doc = store.read("labor", "rv")["문서"]
|
||||
assert doc["담은로직"][0]["고름"] == {"3": WIRE}
|
||||
assert doc["계산값"]["줄"][0]["재료"][0]["품목"]["키"] == WIRE
|
||||
|
||||
|
||||
def test_단가승격도_고름을_따름_없으면_지금_값():
|
||||
expand = refs.Expand(_whole())
|
||||
plain = expand._promote({"담은로직": [{"로직": NET}], "배": 1})
|
||||
picked = expand._promote({"담은로직": [{"로직": NET, "고름": {"3": WIRE}}], "배": 1})
|
||||
assert plain["기준"] == labor.run_line(_whole(), NET, {})["기준"]
|
||||
assert picked["기준"]["재료비"] < plain["기준"]["재료비"]
|
||||
|
||||
|
||||
def test_사본_씨앗은_문서_고름():
|
||||
labor_file = {
|
||||
"줄": {
|
||||
"담은로직": [
|
||||
{"로직": NET, "고름": {"3": WIRE}},
|
||||
{"로직": BARE},
|
||||
{"참조": {"층": "system", "주인": None, "키": "SP-000001"}, "로직": ""},
|
||||
]
|
||||
}
|
||||
}
|
||||
assert snap.seed_picks(labor_file) == {NET: {"3": WIRE}}
|
||||
assert snap.seed_picks(None) == {}
|
||||
|
||||
|
||||
def _release(row: dict, before: dict, mats: list) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_labor_logic_mats.cjs")],
|
||||
input=json.dumps({"row": row, "before": before, "mats": mats}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_입력이_바뀌면_걸린_고름만_풂_화면_짝():
|
||||
mats = labor._json(labor.run_line(_whole(), BORING, {})["재료"])
|
||||
picks = {"4": "MT007895", "9": WIRE}
|
||||
row = {"로직": BORING, "입력값": {"규격": "NX"}, "고름": picks}
|
||||
assert _release(row, {"규격": "NX"}, mats)["고름"] == picks # 그대로
|
||||
assert _release(row, {"규격": "BX"}, mats)["고름"] == {"9": WIRE} # 규격에 걸린 줄만
|
||||
row = {**row, "고름": {"4": "MT007895"}}
|
||||
assert "고름" not in _release(row, {}, mats) # 다 풀리면 칸째 지움
|
||||
@@ -0,0 +1,85 @@
|
||||
"""102 — M02 일위대가 상세 줄 배치 · 줄 통째 끌기 · 구분 열 품셈 글.
|
||||
|
||||
끌기(102-2) = Node 헬퍼(`helper_m02_labor_rowdrag.cjs`)가 `M02_MasterTemplete_Labor_RowDrag.ts` 를 가짜 DOM 에서 돌림 ·
|
||||
6px 문턱 · 끈 뒤 누름 삼킴 · 열린 줄 하위 줄에서 시작 · 단추 · 입력칸 · 수량 칸 막음 · 끄는 동안 다시 그림 미룸.
|
||||
배치(102-1) · 품셈 글(102-3) = 소스 꼴.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_labor_rowdrag.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_문턱_6px(r):
|
||||
assert r["threshold"] == [False, True, True, False, True]
|
||||
|
||||
|
||||
def test_작게_움직이면_누름(r):
|
||||
assert r["small"] == {"moves": 0, "clickPasses": True, "capture": None}
|
||||
|
||||
|
||||
def test_끌어_놓으면_순서만_누름_삼킴(r):
|
||||
assert r["during"]["drawn"] == 0 # 끄는 동안 다시 그리지 않음
|
||||
assert r["during"]["capture"] == 1 and r["during"]["select"] == "none"
|
||||
assert r["during"]["classes"][0] == "is-dragging"
|
||||
assert r["during"]["classes"][5] == "is-dragunder"
|
||||
assert r["drop"]["moves"] == [[0, 2]]
|
||||
assert r["drop"]["drawn"] == 1 # 끝난 뒤 마지막 것 한 번
|
||||
assert r["drop"]["clickPasses"] is False # 열림 · 닫힘 그대로
|
||||
assert set(r["drop"]["classes"]) == {""}
|
||||
assert r["afterFrame"]["listeners"] == 0
|
||||
assert r["idleDraw"] == 1
|
||||
|
||||
|
||||
def test_열린_줄_하위_줄째(r):
|
||||
assert r["fromSub"]["moves"] == [[1, 0]]
|
||||
assert r["fromSub"]["classes"][:5] == ["is-dragover"] + ["is-dragging"] * 4
|
||||
assert r["downMark"][4] == "is-dragunder" and r["downMark"][-1] == "[[0,1]]"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["button", "input", "qty"])
|
||||
def test_단추_입력칸에선_시작_안_함(r, name):
|
||||
assert r[f"block_{name}"] == {"moves": 0, "capture": None, "clickPasses": True}
|
||||
|
||||
|
||||
def test_손잡이_제자리(r):
|
||||
assert r["gripSelf"] == {"moves": 0, "clickPasses": False}
|
||||
|
||||
|
||||
def test_배치_손잡이_차례칸_앞_단추칸():
|
||||
rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8")
|
||||
assert "children: move ? [grip(), String(i + 1)] : [String(i + 1)]," in rows
|
||||
assert 'draggable: "true"' not in rows and "dragFrom" not in rows
|
||||
assert "if (move) attachRowDrag(out, move);" in rows
|
||||
css = (M02 / "M02_MasterTemplete_Labor.css").read_text(encoding="utf-8")
|
||||
order = css[css.index(".m02-labrow__order {") :]
|
||||
assert "flex-direction: column;" in order[: order.index("}")]
|
||||
assert ".m02-labrow__tools > .ui-btn-group {\n align-items: stretch;" in css
|
||||
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8")
|
||||
assert "afterRowDrag(logicHost, () =>" in detail
|
||||
|
||||
|
||||
def test_구분열_품셈_글():
|
||||
rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text(encoding="utf-8")
|
||||
assert "const BOOK = /^(건설|산림)품셈/;" in rows
|
||||
assert 'const book = row.참조 ? undefined : BOOK.exec(logic?.출처 ?? "")?.[0];' in rows
|
||||
assert 'className: "m02-labrow__book"' in rows
|
||||
@@ -74,7 +74,7 @@ def test_sub2_가_부르는_이름과_모양():
|
||||
), name
|
||||
assert "fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>>" in body
|
||||
assert re.search(
|
||||
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;\s*\}",
|
||||
r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;(\s*/\*\*[^*]*\*/)?(\s*합계\w+\?: [^;]+;)*\s*\}",
|
||||
body,
|
||||
)
|
||||
assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰
|
||||
@@ -93,7 +93,7 @@ def test_집계표만_컨테이너를_붙임():
|
||||
# 구조물 도면 저장은 `도면` · `더한장`(82-2) 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
|
||||
# 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`)
|
||||
assert re.search(
|
||||
r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*'
|
||||
r"toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*"
|
||||
r'\.\.\.\(edited as Pick<StructureDoc, "도면" \| "더한장">\),\s*\}\)',
|
||||
body,
|
||||
)
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
out.splitAfter = ctl("inlet", "after").value;
|
||||
await set("inlet", "after", "4");
|
||||
out.splitLength = ctl("inlet", "length").value;
|
||||
// 흙막이 = 유출 「추가 기슭막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나
|
||||
// 흙막이 = 유출 「추가 흙막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나
|
||||
out.soilAddButtons = spec().querySelectorAll('[data-spec-add="soil_add"]').length;
|
||||
out.soilRemoveButtons = spec().querySelectorAll('[data-spec-remove="soil_add"]').length;
|
||||
spec().querySelector('[data-spec-add="chute_add"]').click();
|
||||
|
||||
@@ -239,9 +239,9 @@
|
||||
sec()
|
||||
.querySelectorAll("button")
|
||||
.forEach((b) => flat(b) === "리셋" && b.click());
|
||||
// 다단 벽(추가 기슭막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로)
|
||||
// 다단 벽(추가 흙막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로)
|
||||
out.tier = null;
|
||||
for (const card of cards().filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) {
|
||||
for (const card of cards().filter((c) => /유출 흙막이 선택/.test(c.innerHTML))) {
|
||||
if (/추가\(성토부\)/.test(card.innerHTML)) {
|
||||
out.tier = card.id;
|
||||
break;
|
||||
@@ -306,7 +306,7 @@
|
||||
}
|
||||
// ── 다. 빈 곳 — 구조물 카드 · 빈 카드 · 다시 누름 = 카드 풂 ──
|
||||
await clearCard();
|
||||
const pipeCard = targets.find((c) => /유입 집수정|유출 기슭막이/.test(c.innerHTML));
|
||||
const pipeCard = targets.find((c) => /유입 집수정|유출 흙막이/.test(c.innerHTML));
|
||||
if (pipeCard) {
|
||||
click(pipeCard.querySelector("svg"));
|
||||
await w(1400);
|
||||
@@ -423,12 +423,12 @@
|
||||
judge(`값 되돌림: ${key}`, noSpan(back) === noSpan(before), state());
|
||||
};
|
||||
// 형태 칸은 구조를 고른 관에서만 섬 — 그 칸이 서는 관을 찾음
|
||||
for (const c of targets.filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) {
|
||||
for (const c of targets.filter((c) => /유출 흙막이 선택/.test(c.innerHTML))) {
|
||||
await clearCard();
|
||||
click(uniqueHits(document.getElementById(c.id))[0]);
|
||||
await w(1400);
|
||||
if (!ctlOf("outlet.form")) continue;
|
||||
await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 기슭막이 콘크리트/.test(t));
|
||||
await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 흙막이 콘크리트/.test(t));
|
||||
break;
|
||||
}
|
||||
const fordCard = targets.find((c) => /련/.test(titles(c).join("|")));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* 54-10 — B06 다단 흙막이 ↔ 배관 「흙막이」 컨테이너
|
||||
* 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18)
|
||||
* 부르는 법: orca eval --expression "window.__m5410={mode:'단계', n:0, item:'', value:''}; $(cat 이 파일)"
|
||||
* look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 기슭막이」 칸 · 다단 벽 그림(형태 · 높이)
|
||||
* look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 흙막이」 칸 · 다단 벽 그림(형태 · 높이)
|
||||
* wall n → 횡단도 다단 n 번째(0 부터) 벽을 누름 → look
|
||||
* set n item value → 흙막이 n+1 컨테이너 item(form · height · length · before · after) 값 바꿈 → look
|
||||
* tier+ / tier- → 유출 「추가 기슭막이(단)」 + / -
|
||||
* tier+ / tier- → 유출 「추가 흙막이(단)」 + / -
|
||||
* 기대: wall n 뒤 켜진 상자 = n · 옛 칸 0 · set form → 벽 형태 · set height → 벽 H(한도 안)
|
||||
*/
|
||||
(async () => {
|
||||
@@ -71,7 +71,7 @@
|
||||
),
|
||||
),
|
||||
extraGroupShown: [...document.querySelectorAll("legend")].filter(
|
||||
(l) => visible(l) && l.textContent.startsWith("추가 기슭막이"),
|
||||
(l) => visible(l) && l.textContent.startsWith("추가 흙막이"),
|
||||
).length,
|
||||
tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null,
|
||||
walls: extraShapes(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 부르는 법: orca eval --expression "window.__m5411={mode:'단계'}; $(cat 이 파일)"
|
||||
* look → 단 수 · 보이는 흙막이 상자 · 파일의 흙막이 값 번호 · 집계표 줄 수 · 미지정 알림
|
||||
* prep → 보이는 흙막이 상자마다 구조물 조합 잇기 · 마지막 상자 형태 = form → [저장] → look
|
||||
* tier+ / tier- → 유출 「추가 기슭막이(단)」 + / - → [저장] → look
|
||||
* tier+ / tier- → 유출 「추가 흙막이(단)」 + / - → [저장] → look
|
||||
* 기대: 단 수 2 → 1 에 상자 2 → 1 · 집계 줄(또는 미지정 건) 1 줄어듦 · 파일 값 그대로 ·
|
||||
* 다시 2 에 줄 · 상자 값 되살아남
|
||||
*/
|
||||
|
||||
@@ -108,10 +108,10 @@
|
||||
["inlet", "유입"],
|
||||
["outlet", "유출"],
|
||||
]) {
|
||||
const wall = pic.walls.find((t) => t.startsWith(`${role} 기슭막이`));
|
||||
const wall = pic.walls.find((t) => t.startsWith(`${role} 흙막이`));
|
||||
if (side === "inlet" && basin) continue;
|
||||
const f = head0(fields[`${side}.form`]);
|
||||
if (!wall || flat(wall) !== flat(`${role} 기슭막이 ${f}`))
|
||||
if (!wall || flat(wall) !== flat(`${role} 흙막이 ${f}`))
|
||||
bad(`${head}: ${side} 형태 ${f} ↔ ${wall}`);
|
||||
}
|
||||
const d = Math.round((set?.culvert?.diameter_m ?? 0) * 1000);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* 54-1 — B05 · B06 좌측 패널 배관: 「선택」 글 · 지난 정의 값 숨김 · 관 길이 보여 주는 칸(정수) ·
|
||||
* 유입 집수정 = 길이 · 전 · 후 숨김 · 유출 읽기 줄(관길이 · 좌우 자동 자리) 없음 · [흙막이 추가] 없음 ·
|
||||
* 흙막이 컨테이너 수 = 유출 「추가 기슭막이(단)」 N
|
||||
* 흙막이 컨테이너 수 = 유출 「추가 흙막이(단)」 N
|
||||
* 시작 화면: B05(#/b05-profile) 또는 B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 「배관 1」(4+5)
|
||||
* 부르는 법: orca eval --expression "window.__m541='단계'; $(cat 이 파일)"
|
||||
* look → 지금 화면 배관 폼 수치(아무것도 안 바꿈)
|
||||
* basin → 유입 구조 집수정 · 흙막이 번갈아 보이는 칸(되돌림 · 저장 안 함)
|
||||
* tier+ / tier- → B06 유출 벽을 골라 「추가 기슭막이(단)」 + / - 한 번 · 흙막이 컨테이너 수
|
||||
* tier+ / tier- → B06 유출 벽을 골라 「추가 흙막이(단)」 + / - 한 번 · 흙막이 컨테이너 수
|
||||
* 기대: pick 글 전부 「선택」 · 고르기 0 · stale 보임 0 · 관 길이 input 0 · 값 정수 ·
|
||||
* 집수정 칸 = structure · form · height · 흙막이 칸 = + length · before · after ·
|
||||
* soil_add 추가 단추 0 · 유출 읽기 줄 0 · soil_add 수 = 단 수
|
||||
@@ -86,7 +86,7 @@
|
||||
outletText: box("outlet")?.textContent.includes("자동 자리"),
|
||||
tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null,
|
||||
extraGroupShown: [...document.querySelectorAll("legend")].filter(
|
||||
(l) => visible(l) && l.textContent.startsWith("추가 기슭막이"),
|
||||
(l) => visible(l) && l.textContent.startsWith("추가 흙막이"),
|
||||
).length,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
for (const form of forms) {
|
||||
await set(side, "form", form);
|
||||
const poly = [...card().querySelectorAll(".b06-chart__culvert-revet")].find((p) =>
|
||||
(p.querySelector("title")?.textContent ?? "").startsWith(`${word} 기슭막이`),
|
||||
(p.querySelector("title")?.textContent ?? "").startsWith(`${word} 흙막이`),
|
||||
);
|
||||
const hatch = poly.nextElementSibling?.nextElementSibling;
|
||||
rows.push({
|
||||
|
||||
@@ -107,6 +107,19 @@
|
||||
background: color-mix(in srgb, var(--color-lavender-wash) 14%, var(--color-canvas));
|
||||
}
|
||||
|
||||
/* 구조물 조합 일위대가 합계 칸(101-1) — 초록 모두 반영 · 노랑 일부 빠짐 · 붉음 기준 단가 안 나옴(설계값·계산 칸보다 진함) */
|
||||
.ui-sheet__table td.is-sum-ok {
|
||||
background: color-mix(in srgb, var(--color-success) 26%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-sum-short {
|
||||
background: color-mix(in srgb, var(--color-warning) 34%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-sum-error {
|
||||
background: color-mix(in srgb, var(--color-danger) 28%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet--project .ui-sheet__table tbody td.is-locked {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
@@ -141,6 +154,43 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 구조물 조합 — 고른 열의 머리 칸 + 합계 칸을 한 상자로(머리 아래 · 합계 위 변 없음 = 중간선 없음) */
|
||||
.ui-sheet--combo .ui-sheet__table:has(tr.ui-sheet__meta td.is-selected) th.is-head-picked {
|
||||
border-bottom-width: 0;
|
||||
outline: 0;
|
||||
box-shadow:
|
||||
inset 2px 0 0 var(--color-focus-ring),
|
||||
inset -2px 0 0 var(--color-focus-ring),
|
||||
inset 0 2px 0 var(--color-focus-ring);
|
||||
}
|
||||
|
||||
.ui-sheet--combo .ui-sheet__table tr.ui-sheet__meta td.is-selected {
|
||||
outline: 0;
|
||||
box-shadow:
|
||||
inset 2px 0 0 var(--color-focus-ring),
|
||||
inset -2px 0 0 var(--color-focus-ring),
|
||||
inset 0 -2px 0 var(--color-focus-ring);
|
||||
}
|
||||
|
||||
/* 틀고정 칸의 오른쪽 선 그림자(:87-91)와 합침 */
|
||||
.ui-sheet--combo
|
||||
.ui-sheet__table:has(tr.ui-sheet__meta td.is-selected)
|
||||
th.is-head-picked.is-frozen {
|
||||
box-shadow:
|
||||
inset 2px 0 0 var(--color-focus-ring),
|
||||
inset -2px 0 0 var(--color-focus-ring),
|
||||
inset 0 2px 0 var(--color-focus-ring),
|
||||
1px 0 0 var(--color-border);
|
||||
}
|
||||
|
||||
.ui-sheet--combo .ui-sheet__table tr.ui-sheet__meta td.is-selected.is-frozen {
|
||||
box-shadow:
|
||||
inset 2px 0 0 var(--color-focus-ring),
|
||||
inset -2px 0 0 var(--color-focus-ring),
|
||||
inset 0 -2px 0 var(--color-focus-ring),
|
||||
1px 0 0 var(--color-border);
|
||||
}
|
||||
|
||||
.ui-sheet__table tr.is-fixed td {
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
addLevel,
|
||||
deleteColumn,
|
||||
isBlankPick,
|
||||
KEY_SUM,
|
||||
KEY_UNIT,
|
||||
navRows,
|
||||
PICK_ROWS,
|
||||
@@ -61,6 +62,8 @@ export interface SheetOptions {
|
||||
areaLabel?: (colId: string) => string;
|
||||
/** 구조물 조합 표(`combo`) 「일위대가 합계」 줄 칸 글 — 주면 읽기 전용 줄이 생김(86-3). */
|
||||
laborSumLabel?: (colId: string) => string;
|
||||
/** 합계 칸 색 상태 · 툴팁 까닭(101-1) — `state` = ok · short · error · 없으면 색 없음. */
|
||||
laborSumState?: (colId: string) => { state: string; why: string } | undefined;
|
||||
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
|
||||
onSelect?: (sel: SheetSelection) => void;
|
||||
/** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름.
|
||||
@@ -93,6 +96,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
drawingLabel: opts.drawingLabel,
|
||||
areaLabel: opts.areaLabel,
|
||||
laborSumLabel: opts.laborSumLabel,
|
||||
laborSumState: opts.laborSumState,
|
||||
pick: !!opts.onPick,
|
||||
headLocked: !!opts.lockHead,
|
||||
combo: !!opts.combo,
|
||||
@@ -186,7 +190,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
state.result = recalcSheet(state.doc);
|
||||
const { scrollLeft, scrollTop } = scroll;
|
||||
scroll.replaceChildren(renderSheet(state));
|
||||
if (opts.combo && state.sel) deepestHead(state.sel.c)?.classList.add("is-head-picked");
|
||||
if (opts.combo && state.sel) {
|
||||
deepestHead(state.sel.c)?.classList.add("is-head-picked");
|
||||
markSum(state.sel.c);
|
||||
}
|
||||
scroll.scrollLeft = scrollLeft;
|
||||
scroll.scrollTop = scrollTop;
|
||||
syncToolbar();
|
||||
@@ -212,6 +219,10 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
}
|
||||
return best;
|
||||
};
|
||||
/** 구조물 조합 — 머리를 고르면 그 열 합계 칸도 늘 같이 고름 표시(101-2) */
|
||||
const markSum = (colId: string): void => {
|
||||
for (const cell of cellsAt(KEY_SUM, colId)) cell.classList.add("is-selected");
|
||||
};
|
||||
const select = (r: string, c: string, reveal = false): void => {
|
||||
for (const cell of scroll.querySelectorAll(".is-selected, .is-head-picked"))
|
||||
cell.classList.remove("is-selected", "is-head-picked");
|
||||
@@ -220,6 +231,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
head?.classList.add("is-head-picked");
|
||||
const cells = cellsAt(r, c);
|
||||
for (const cell of cells) cell.classList.add("is-selected");
|
||||
if (opts.combo) markSum(c);
|
||||
if (reveal) (cells[0] ?? head)?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
syncToolbar();
|
||||
opts.onSelect?.(selectionOf(state.doc, r, c));
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface RenderState {
|
||||
areaLabel?: (colId: string) => string;
|
||||
/** 구조물 조합 표 「일위대가 합계」 줄 칸 글 — 주면 combo 표에 읽기 전용 줄이 생김(86-3). */
|
||||
laborSumLabel?: (colId: string) => string;
|
||||
/** 합계 칸 색 상태 · 툴팁 까닭(101-1) */
|
||||
laborSumState?: (colId: string) => { state: string; why: string } | undefined;
|
||||
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
|
||||
pick?: boolean;
|
||||
/** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */
|
||||
@@ -194,6 +196,14 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글
|
||||
const td = bodyCell(key, col, j, text);
|
||||
td.classList.remove("is-num");
|
||||
if (key === KEY_SUM) {
|
||||
td.classList.remove("is-calc", "is-bound", "is-hand"); // 합계 칸은 열 꼴 색 없음 — 상태 색만
|
||||
const sum = blank ? undefined : state.laborSumState?.(col.id);
|
||||
if (sum) {
|
||||
td.classList.add(`is-sum-${sum.state}`);
|
||||
td.title = sum.why;
|
||||
}
|
||||
}
|
||||
if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick");
|
||||
tr.append(td);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user