Merge remote-tracking branch 'origin/dev' into main_desktop_1
This commit is contained in:
@@ -65,6 +65,7 @@ export function createDesignDock(
|
||||
divider.className = "b05-structure__divider";
|
||||
const row = document.createElement("div");
|
||||
row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions";
|
||||
row.dataset.helpRow = "";
|
||||
|
||||
const press = (name: keyof DesignDockActions) => (): void => {
|
||||
void Promise.resolve(actions[name].run());
|
||||
|
||||
@@ -53,7 +53,7 @@ import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile
|
||||
import {
|
||||
structureGroupMenuItems,
|
||||
stationIdAtStructure as stationIdAtStructureOf,
|
||||
structureIdAtStation as structureIdAtStationOf,
|
||||
structureIdKeeping,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import {
|
||||
@@ -261,9 +261,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
|
||||
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
|
||||
const structureIdAtStation = (stationId: string | null): string | null =>
|
||||
stationId !== null && stationIdAtStructure(selectedStructureId) === stationId
|
||||
? selectedStructureId
|
||||
: structureIdAtStationOf(stationId, irregularStations, structures);
|
||||
structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail);
|
||||
const stationIdAtStructure = (structureId: string | null): string | null =>
|
||||
stationIdAtStructureOf(structureId, irregularStations, structures);
|
||||
|
||||
@@ -626,8 +624,10 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
},
|
||||
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
|
||||
setSelectedStructure(structureId: string | null) {
|
||||
// 지금 측점(B06 카드)이 그 구조물 자리면 측점선도 그대로(53-2)
|
||||
selectedStructureId = structureId;
|
||||
selectedStationId = stationIdAtStructure(structureId);
|
||||
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
|
||||
selectedStationId = keep ? selectedStationId : stationIdAtStructure(structureId);
|
||||
draw();
|
||||
},
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||||
|
||||
@@ -136,8 +136,9 @@ export function subscribeSelection(projectId: string | null, fn: SelectionListen
|
||||
}
|
||||
|
||||
/**
|
||||
* 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 이미 적힌 부재키를 지킨다 — 3D 로
|
||||
* 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다.
|
||||
* 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 **같은 것**을 다시 적을 때만 부재키를 지킨다 —
|
||||
* 3D 로 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다.
|
||||
* 다른 구조물로 옮기면 버리고, 빈 값("")을 주면 지운다(53-1).
|
||||
* `from` 은 낸 쪽 이름 — 구독자가 제 것을 되받아 다시 그리지 않게 거른다.
|
||||
* 값이 그대로면 알리지 않고, 알림 도중 다시 낸 값은 적기만 한다(되울림 방지).
|
||||
*/
|
||||
@@ -157,7 +158,17 @@ export function selectDesign(
|
||||
before.structureId === selection.structureId &&
|
||||
(options.partKey === undefined || options.partKey === before.partKey);
|
||||
if (same) return;
|
||||
const key = options.partKey ?? before?.partKey;
|
||||
// 부재키는 같은 것을 다시 적을 때만 지킴 — 다른 구조물로 옮기면 옛 부재(조정창)가 따라오지 않음(53-1)
|
||||
const sameTarget =
|
||||
before !== null &&
|
||||
before.chainageM !== null &&
|
||||
isNear(before.chainageM, selection.chainageM) &&
|
||||
before.structureId === selection.structureId;
|
||||
// 빈 부재키("") = 부재를 풂
|
||||
const key =
|
||||
options.partKey === ""
|
||||
? undefined
|
||||
: (options.partKey ?? (sameTarget ? before?.partKey : undefined));
|
||||
writeState(
|
||||
"structure-pick",
|
||||
{ at: selection.chainageM, key, sid: selection.structureId ?? undefined },
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/* =============================================================================
|
||||
* design_structure_box.ts
|
||||
* 횡단배수 › 박스암거 명세 — 사용자 구조물 표 02 고친 판(`docs/raw/구조물정의_표/02_횡단배수_박스암거.md`) 그대로.
|
||||
*
|
||||
* 컨테이너 셋(모두 필수): 본체 · 유입 구조 · 유출 구조. 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
|
||||
* 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리):
|
||||
* · 본체 = `body_width_m` · `body_height_m` 수 — 규격 칸은 저장 안 함(폭 · 높이로 되찾음 · `bodyOf`)
|
||||
* · 사용자 입력에 두 칸 다 비면 `body_custom` = 「사용자 입력」 표시만
|
||||
* · 유입 · 유출 구조 = `wing_in` · `wing_out`(없음 · 날개벽 · 흙막이 · 옛 「있음」 = 날개벽)
|
||||
* — 엔진은 날개벽만 세움(`_wing_spec` ↔ `wingSpec` 짝)
|
||||
* 표 02 에 없는 옛 날개벽 치수 칸은 화면에서 빼고 값은 남김(지난 정의 값).
|
||||
* import 없음(꼴만) — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Instance, SpecItem, StructureSpec } from "./design_structure_spec";
|
||||
|
||||
export const BOX_CUSTOM = "사용자 입력";
|
||||
/** 사용자 입력에 가로 · 세로 둘 다 빈 채 저장했다는 표시(폭 · 높이 수가 없을 때만 적음). */
|
||||
export const BOX_CUSTOM_KEY = "body_custom";
|
||||
/** 본체 규격 → [가로, 세로](m) — 실무 관측 규격(2026-08-17 사용자 확정). */
|
||||
const BOX_PRESETS: Record<string, readonly [number, number]> = {
|
||||
"2.0×2.0": [2, 2],
|
||||
"3.0×3.0": [3, 3],
|
||||
};
|
||||
|
||||
/** 유입 · 유출 구조 — 같은 선택지 · 키만 다름. */
|
||||
const structureItem = (key: string): SpecItem => ({
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
key,
|
||||
choices: [
|
||||
{ value: "없음", label: "없음" },
|
||||
{ value: "날개벽", label: "날개벽" },
|
||||
{ value: "흙막이", label: "흙막이" },
|
||||
],
|
||||
});
|
||||
|
||||
/** 사용자 입력일 때만 서는 숫자 칸. */
|
||||
const customSide = (id: string, label: string, key: string): SpecItem => ({
|
||||
id,
|
||||
label,
|
||||
kind: "number",
|
||||
step: 0.1,
|
||||
by: { item: "size", map: { [BOX_CUSTOM]: { key } } },
|
||||
});
|
||||
|
||||
export const BOX_SPEC: StructureSpec = {
|
||||
typeId: "box_culvert",
|
||||
containers: [
|
||||
{
|
||||
id: "body",
|
||||
title: "본체",
|
||||
required: true,
|
||||
link: "gboxbody",
|
||||
items: [
|
||||
{
|
||||
id: "size",
|
||||
label: "본체 규격",
|
||||
kind: "select",
|
||||
choices: [
|
||||
...Object.keys(BOX_PRESETS).map((value) => ({
|
||||
value,
|
||||
label: value.replace("×", " × "),
|
||||
})),
|
||||
{ value: BOX_CUSTOM, label: BOX_CUSTOM },
|
||||
],
|
||||
},
|
||||
customSide("width", "가로 (m)", "body_width_m"),
|
||||
customSide("height", "세로 (m)", "body_height_m"),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "inlet",
|
||||
title: "유입 구조",
|
||||
required: true,
|
||||
link: "gboxin",
|
||||
items: [structureItem("wing_in")],
|
||||
},
|
||||
{
|
||||
id: "outlet",
|
||||
title: "유출 구조",
|
||||
required: true,
|
||||
link: "gboxout",
|
||||
items: [structureItem("wing_out")],
|
||||
},
|
||||
],
|
||||
// 표 02 에 없는 옛 날개벽 치수 — 화면에서 빼고 값은 남김(지난 정의 값)
|
||||
retired: ["in", "out"].flatMap((side) =>
|
||||
["height_m", "length_m", "angle_deg"].map((field) => `wing_${side}_${field}`),
|
||||
),
|
||||
};
|
||||
|
||||
/** 옛 저장 → 폼에 줄 옵션 — 설치 「있음」 = 날개벽(같은 뜻 · 저장하면 새 값으로 굳음). */
|
||||
export function boxOptionsIn(
|
||||
options: Record<string, string | number>,
|
||||
): Record<string, string | number> {
|
||||
const out = { ...options };
|
||||
for (const key of ["wing_in", "wing_out"]) if (out[key] === "있음") out[key] = "날개벽";
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 손 값이 하나라도 있거나 사용자 입력 표시면
|
||||
* 사용자 입력(빈 칸은 빈 채) · 아니면 빈 값. */
|
||||
export function bodyOf(options: Record<string, string | number>): Instance {
|
||||
const width = Number(options.body_width_m);
|
||||
const height = Number(options.body_height_m);
|
||||
const preset = Object.entries(BOX_PRESETS).find(
|
||||
([, [w, h]]) => Math.abs(w - width) < 0.05 && Math.abs(h - height) < 0.05,
|
||||
);
|
||||
if (preset) return { size: preset[0] };
|
||||
if (!(width > 0) && !(height > 0) && options[BOX_CUSTOM_KEY] !== BOX_CUSTOM) return { size: "" };
|
||||
const side = (n: number): string => (n > 0 ? String(n) : "");
|
||||
return { size: BOX_CUSTOM, width: side(width), height: side(height) };
|
||||
}
|
||||
|
||||
/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음) · 사용자 입력에 두 칸 다
|
||||
* 비면 표시 키만(다시 열 때 「사용자 입력」). */
|
||||
export function bodyOptions(body: Instance | undefined): Record<string, string | number> {
|
||||
const preset = BOX_PRESETS[body?.size ?? ""];
|
||||
if (preset) return { body_width_m: preset[0], body_height_m: preset[1] };
|
||||
const blank = !(body?.width ?? "").trim() && !(body?.height ?? "").trim();
|
||||
return body?.size === BOX_CUSTOM && blank ? { [BOX_CUSTOM_KEY]: BOX_CUSTOM } : {};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* =============================================================================
|
||||
* design_structure_ford.ts
|
||||
* 횡단배수 › 세월교 명세 — 사용자 구조물 표 04(`docs/raw/구조물정의_표/04_횡단배수_세월교.md`) 그대로.
|
||||
*
|
||||
* 컨테이너 셋: 세월교(필수) · 유입 구조(필수) · 유출 구조(필수). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
|
||||
* 저장 키 = pipe_points.json 옛 키 그대로(B06 세월교 세트 · B07 · B08 이 읽는 자리) — 새 키 = 유입 · 유출 구조
|
||||
* (`inlet_type` · `outlet_type` = 없음 · 날개벽 · 흙막이) · 고르면 옛 날개벽 설치 칸(`wing_in` · `wing_out`)도 함께 적음.
|
||||
* 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래로는 안 받음) — 계산은 `design_structure_ford_calc`.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SpecContainer, SpecChoice, StructureSpec } from "./design_structure_spec";
|
||||
|
||||
/** 유입 · 유출 구조 — 날개벽만 옛 설치 칸이 「있음」. */
|
||||
const guard = (side: "in" | "out"): SpecChoice[] => [
|
||||
{ value: "없음", label: "없음", also: { [`wing_${side}`]: "없음" } },
|
||||
{ value: "날개벽", label: "날개벽", also: { [`wing_${side}`]: "있음" } },
|
||||
{ value: "흙막이", label: "흙막이", also: { [`wing_${side}`]: "없음" } },
|
||||
];
|
||||
|
||||
const guardContainer = (id: "inlet" | "outlet", title: string, link: string): SpecContainer => ({
|
||||
id,
|
||||
title,
|
||||
required: true,
|
||||
link,
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
key: `${id}_type`,
|
||||
choices: guard(id === "inlet" ? "in" : "out"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const FORD_SPEC: StructureSpec = {
|
||||
typeId: "ford_bridge",
|
||||
containers: [
|
||||
{
|
||||
id: "ford",
|
||||
title: "세월교",
|
||||
required: true,
|
||||
link: "gford",
|
||||
items: [
|
||||
{
|
||||
id: "diameter",
|
||||
label: "관경",
|
||||
kind: "select",
|
||||
key: "pipe_diameter_mm",
|
||||
asNumber: true,
|
||||
choices: ["800", "1000", "1200", "1500"].map((v) => ({ value: v, label: v })),
|
||||
},
|
||||
{ id: "width", label: "월류 폭 (m)", kind: "number", key: "ford_width_m", step: 0.1 },
|
||||
{ id: "height", label: "월류 높이 (m)", kind: "number", key: "ford_height_m", step: 0.01 },
|
||||
{ id: "count", label: "수량 (련)", kind: "number", key: "pipe_count", step: 1 },
|
||||
],
|
||||
},
|
||||
guardContainer("inlet", "유입 구조", "gfordin"),
|
||||
guardContainer("outlet", "유출 구조", "gfordout"),
|
||||
],
|
||||
// 표 04 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
|
||||
retired: [
|
||||
"pipe_kind",
|
||||
"wing_in_height_m",
|
||||
"wing_in_length_m",
|
||||
"wing_in_angle_deg",
|
||||
"wing_out_height_m",
|
||||
"wing_out_length_m",
|
||||
"wing_out_angle_deg",
|
||||
],
|
||||
};
|
||||
|
||||
/** 구조를 날개벽 설치 칸(옛 저장 · B06 조정창이 고치는 칸)에 맞춤 — 있음 = 날개벽 · 없음 = 날개벽이면 없음
|
||||
* (흙막이는 그대로) · 설치 칸이 없으면 구조 그대로. */
|
||||
export function withLegacyGuards(
|
||||
options: Record<string, string | number>,
|
||||
): Record<string, string | number> {
|
||||
const out = { ...options };
|
||||
for (const [type, wing] of [
|
||||
["inlet_type", "wing_in"],
|
||||
["outlet_type", "wing_out"],
|
||||
]) {
|
||||
if (out[wing] === "있음") out[type] = "날개벽";
|
||||
else if (out[wing] === "없음" && (out[type] ?? "날개벽") === "날개벽") out[type] = "없음";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* =============================================================================
|
||||
* design_structure_ford_calc.ts
|
||||
* 세월교 명세 계산 — 월류 폭 ↔ 월류 높이 연동(PLAN 52-4).
|
||||
*
|
||||
* 높이 = 담당 유역 설계유량을 그 월류 폭으로 넘기는 필요 수심(Manning 개수로 · `fordSection` 그대로).
|
||||
* 근거: Manning 공식 `resources/knowledge/technical_info/01_임도/03_계산정보/통수단면.md:23~33`
|
||||
* (KDS 44 40 00 2.7.1~2.7.2) · 조도계수 0.017 = 같은 파일 :59(콘크리트 수로 보통 · 울진 물넘이) ·
|
||||
* 경사 10 % = 실무 수리계산서 역산값(`config_frontend.FORD_SLOPE` · 지식DB 수치 없음).
|
||||
* 폭을 바꾸면 높이 칸을 이 값으로 채움 · 사용자는 올려 적을 수 있고 이 값 아래로는 안 받음.
|
||||
* 높이는 저장되는 입력값 — 서버는 받은 높이로 노면을 내림(`B06_Section_Engine_Culvert._ford_set`) · 재계산 안 함.
|
||||
* 설계유량이 없으면 null = 손 입력.
|
||||
* ========================================================================== */
|
||||
|
||||
import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
|
||||
|
||||
/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */
|
||||
export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null {
|
||||
const section = fordSection(designFlowM3s, widthM);
|
||||
return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null;
|
||||
}
|
||||
|
||||
/** 높이 빈 칸 안내. */
|
||||
export function fordHeightHint(designFlowM3s: number | null, widthM: number): string {
|
||||
const need = fordHeightM(designFlowM3s, widthM);
|
||||
if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`;
|
||||
return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 손 입력";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* =============================================================================
|
||||
* design_structure_ford_pavement.ts
|
||||
* 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로.
|
||||
*
|
||||
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임).
|
||||
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure`.
|
||||
* 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StructureSpec } from "./design_structure_spec";
|
||||
|
||||
export const FORD_PAVEMENT_SPEC: StructureSpec = {
|
||||
typeId: "ford_pavement",
|
||||
containers: [
|
||||
{
|
||||
id: "slope",
|
||||
title: "경사각도",
|
||||
required: true,
|
||||
link: "gfordpaveslope",
|
||||
items: [
|
||||
{ id: "angle", label: "경사각도 (°)", kind: "number", key: "ford_slope_deg", step: 0.1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chute",
|
||||
title: "도수로/돌붙임",
|
||||
required: false,
|
||||
prefix: "ford_chute",
|
||||
link: "gfordpavechute",
|
||||
max: 1,
|
||||
items: [
|
||||
{
|
||||
id: "structure",
|
||||
label: "구조",
|
||||
kind: "select",
|
||||
choices: [
|
||||
{ value: "도수로", label: "도수로" },
|
||||
{ value: "돌붙임", label: "돌붙임" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
|
||||
retired: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
|
||||
};
|
||||
@@ -2,8 +2,9 @@
|
||||
* design_structure_form.ts
|
||||
* 구조물 명세(`design_structure_spec`) 그리기 — B05 · B06 「구조물 배치」 칸이 같은 노드를 씀.
|
||||
*
|
||||
* · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기]
|
||||
* · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 자동(읽기만)
|
||||
* · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] ·
|
||||
* `max` 에 차면 [추가] 숨김
|
||||
* · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 글(손 입력) · 자동(읽기만)
|
||||
* · 인스턴스마다 「구조물 조합」 잇기 줄(`buildSummaryRow` · 고르기 모달 = 패널의 그룹 [고르기] 길 그대로) —
|
||||
* 값은 패널 `group_items` 에 `linkOf` 키로 둠(빼면 뒤 인스턴스 잇기를 한 칸씩 당김)
|
||||
* · 값은 폼이 쥠 — 저장은 패널이 `read()` 로 읽어 정본 자리(옵션)에 씀
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
linkOf,
|
||||
readState,
|
||||
solveSplit,
|
||||
TEXT_MAX,
|
||||
writeOptions,
|
||||
type SpecContainer,
|
||||
type SpecItem,
|
||||
@@ -111,6 +113,13 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
select.value = now;
|
||||
return select;
|
||||
}
|
||||
if (item.kind === "text") {
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.maxLength = TEXT_MAX;
|
||||
input.value = values[item.id] ?? "";
|
||||
return input;
|
||||
}
|
||||
const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? "");
|
||||
if (item.kind === "auto") {
|
||||
input.type = "text";
|
||||
@@ -167,7 +176,18 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
return wrap;
|
||||
});
|
||||
box.body.append(grid(...fields));
|
||||
if (c.required) box.body.append(slotOf(c.id));
|
||||
if (c.required) {
|
||||
const slot = slotOf(c.id);
|
||||
// B06 조정 강조(테두리 · 이름표)는 칸 상자에 붙음 — 다시 그려도 이어 감(53-1)
|
||||
const prev = slot.closest("fieldset");
|
||||
if (prev?.classList.contains("is-active")) {
|
||||
const legend = box.root.querySelector("legend")!;
|
||||
legend.dataset.baseLabel = legend.textContent ?? "";
|
||||
legend.textContent = prev.querySelector("legend")?.textContent ?? legend.textContent;
|
||||
box.root.classList.add("is-active");
|
||||
}
|
||||
box.body.append(slot);
|
||||
}
|
||||
if (links) {
|
||||
const key = linkOf(c, index + 1);
|
||||
const row = buildSummaryRow();
|
||||
@@ -208,7 +228,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
for (const c of spec?.containers ?? []) {
|
||||
const list = (state[c.id] ??= c.required ? [{}] : []);
|
||||
list.forEach((values, index) => boxes.push(instanceBox(c, values, index)));
|
||||
if (c.required) continue;
|
||||
if (c.required || (c.max && list.length >= c.max)) continue;
|
||||
const add = document.createElement("button");
|
||||
add.type = "button";
|
||||
add.className = "b05-route__irregular-btn b05-structure__spec-add";
|
||||
|
||||
@@ -8,10 +8,14 @@
|
||||
* (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`).
|
||||
* 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림.
|
||||
* 계산(관 길이 · 높이 안내)은 `design_structure_pipe_calc`.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec";
|
||||
import { BOX_SPEC } from "./design_structure_box";
|
||||
import { FORD_SPEC } from "./design_structure_ford";
|
||||
import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement";
|
||||
import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road";
|
||||
|
||||
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
|
||||
const BASIN = "집수정";
|
||||
@@ -184,4 +188,11 @@ export const PIPE_SPEC: StructureSpec = {
|
||||
};
|
||||
|
||||
/** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */
|
||||
export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = { pipe: PIPE_SPEC };
|
||||
export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = {
|
||||
pipe: PIPE_SPEC,
|
||||
box_culvert: BOX_SPEC,
|
||||
ford_bridge: FORD_SPEC,
|
||||
ford_pavement: FORD_PAVEMENT_SPEC,
|
||||
pavement_concrete: PAVEMENT_SPEC,
|
||||
etc: ETC_SPEC,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* =============================================================================
|
||||
* design_structure_road.ts
|
||||
* 노면공 › 콘크리트 포장 명세 — 사용자 구조물 표 08(`docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md`).
|
||||
*
|
||||
* 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력.
|
||||
* 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김.
|
||||
* 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`.
|
||||
* 기타(52-14) = 기준 측점 + 상세 종류 이름(정의 옵션 칸 `name`) + 선택 컨테이너 「상세 내용」(비고 글) ·
|
||||
* 저장 키 `etc_detail_{n}_note` · 잇기 `getcdetail{n}`.
|
||||
* import 없음 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SpecChoice, StructureSpec } from "./design_structure_spec";
|
||||
|
||||
/** 포장사양 선택지 — 표의 나머지 값은 사용자 답 뒤 여기에만 더함. */
|
||||
export const PAVE_SPEC_CHOICES: SpecChoice[] = [{ value: "콘크리트", label: "콘크리트" }];
|
||||
|
||||
export const PAVEMENT_SPEC: StructureSpec = {
|
||||
typeId: "pavement_concrete",
|
||||
containers: [
|
||||
{
|
||||
id: "detail",
|
||||
title: "상세 내용",
|
||||
required: true,
|
||||
link: "gpavedetail",
|
||||
items: [
|
||||
{
|
||||
id: "spec",
|
||||
label: "포장사양",
|
||||
kind: "select",
|
||||
key: "pave_spec",
|
||||
choices: PAVE_SPEC_CHOICES,
|
||||
},
|
||||
{ id: "height", label: "높이 (m)", kind: "number", key: "height_m", step: 0.05 },
|
||||
],
|
||||
},
|
||||
],
|
||||
// 표 08 에 없는 옛 상세 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
|
||||
retired: [
|
||||
"thickness_cm",
|
||||
"width_m",
|
||||
"widening_area_m2",
|
||||
"joint_spacing_m",
|
||||
"separation_sheet",
|
||||
"wire_mesh",
|
||||
],
|
||||
};
|
||||
|
||||
export const ETC_SPEC: StructureSpec = {
|
||||
typeId: "etc",
|
||||
containers: [
|
||||
{
|
||||
id: "detail",
|
||||
title: "상세 내용",
|
||||
required: false,
|
||||
prefix: "etc_detail",
|
||||
link: "getcdetail",
|
||||
max: 1,
|
||||
items: [{ id: "note", label: "비고", kind: "text" }],
|
||||
},
|
||||
],
|
||||
retired: [],
|
||||
};
|
||||
@@ -3,8 +3,9 @@
|
||||
* 구조물 명세 공용 틀(PLAN 52) — 화면 요소 없는 순수 규칙. 사용자 구조물 표 한 장 = 명세 한 벌.
|
||||
*
|
||||
* · 종류 = 기본 옵션(기준 측점 No + 거리 · 패널 위치 칸이 받음) + 컨테이너[] + 항목[]
|
||||
* · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개)
|
||||
* · 항목 꼴 = 고르기 · 숫자 · 자동(계산이 채움 · 저장 안 함) · 맞물림(길이 = 전 + 후)
|
||||
* · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개 · `max` 면 그 수까지)
|
||||
* · 항목 꼴 = 고르기 · 숫자 · 글(손 입력 · 길이 상한 `TEXT_MAX`) · 자동(계산이 채움 · 저장 안 함) ·
|
||||
* 맞물림(길이 = 전 + 후)
|
||||
* · 상위 값에 따른 선택지 · 저장 키 = `by`(같은 컨테이너 안 상위 항목 값 → 갈래)
|
||||
* · 저장 = 정본 자리 옵션 그대로(관이면 pipe_points.json `options`) — 필수 컨테이너는 항목 `key` ·
|
||||
* 선택 컨테이너는 인스턴스마다 `{prefix}_{n}_{항목}`(n 은 1부터)
|
||||
@@ -31,7 +32,7 @@ export type SplitRole = "length" | "before" | "after";
|
||||
export interface SpecItem {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "select" | "number" | "auto";
|
||||
kind: "select" | "number" | "text" | "auto";
|
||||
/** 저장 키(필수 컨테이너 · `by` 없을 때). */
|
||||
key?: string;
|
||||
choices?: SpecChoice[];
|
||||
@@ -52,6 +53,8 @@ export interface SpecContainer {
|
||||
required: boolean;
|
||||
/** 선택 컨테이너 저장 키 머리 — `{prefix}_{n}_{항목}`. */
|
||||
prefix?: string;
|
||||
/** 선택 컨테이너 개수 상한 — 차면 [추가] 숨김(옛 저장이 넘쳐도 다 보임). 없으면 여러 개. */
|
||||
max?: number;
|
||||
/** 구조물 조합 잇기 키 — 필수는 정의 그룹 id 그대로 · 선택은 뒤에 n. */
|
||||
link: string;
|
||||
items: SpecItem[];
|
||||
@@ -64,6 +67,9 @@ export interface StructureSpec {
|
||||
retired: string[];
|
||||
}
|
||||
|
||||
/** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */
|
||||
export const TEXT_MAX = 100;
|
||||
|
||||
/** 컨테이너 인스턴스 한 벌 값 `{항목 id: 글}`. */
|
||||
export type Instance = Record<string, string>;
|
||||
export type SpecState = Record<string, Instance[]>;
|
||||
@@ -135,7 +141,8 @@ export function readState(
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김). */
|
||||
/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김 ·
|
||||
* 다 빈 선택 인스턴스는 첫 칸을 빈 글로 남겨 자리를 지킴 — 잇기 · 집계 줄 · 다시 열기). */
|
||||
export function writeOptions(
|
||||
spec: StructureSpec,
|
||||
state: SpecState,
|
||||
@@ -143,6 +150,7 @@ export function writeOptions(
|
||||
const out: Record<string, string | number> = {};
|
||||
for (const c of spec.containers) {
|
||||
(state[c.id] ?? []).forEach((values, index) => {
|
||||
const before = Object.keys(out).length;
|
||||
for (const item of c.items) {
|
||||
const key = keyOf(c, item, index + 1, values);
|
||||
const raw = (values[item.id] ?? "").trim();
|
||||
@@ -152,10 +160,13 @@ export function writeOptions(
|
||||
if (Number.isFinite(n)) out[key] = Math.round(n * 100) / 100;
|
||||
continue;
|
||||
}
|
||||
out[key] = raw;
|
||||
out[key] = item.kind === "text" ? raw.slice(0, TEXT_MAX) : raw;
|
||||
if (c.required)
|
||||
Object.assign(out, choicesOf(item, values).find((ch) => ch.value === raw)?.also);
|
||||
}
|
||||
const first = c.items.find((item) => item.kind !== "auto");
|
||||
if (!c.required && first && Object.keys(out).length === before)
|
||||
out[instanceKey(c, index + 1, first)] = "";
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -179,6 +179,7 @@ export async function mountCrossView(
|
||||
focusChainage: (chainageM) => {
|
||||
if (!isWallSelecting()) focusStationAt(chainageM);
|
||||
},
|
||||
markStructure: (structureId) => sectionView.selectMark(structureId),
|
||||
applyPipeOptions: (chainageM, patch) =>
|
||||
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
|
||||
});
|
||||
@@ -202,11 +203,15 @@ export async function mountCrossView(
|
||||
view: () => sectionView,
|
||||
});
|
||||
|
||||
let structuresRefreshSeq = 0;
|
||||
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
|
||||
async function refreshDetailForStructures(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
|
||||
try {
|
||||
sectionDetail = await loadSectionDetail(projectId, currentRouteId);
|
||||
const fresh = await loadSectionDetail(projectId, currentRouteId);
|
||||
if (seq !== structuresRefreshSeq) return;
|
||||
sectionDetail = fresh;
|
||||
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가
|
||||
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측).
|
||||
if (sectionDetail) {
|
||||
@@ -316,6 +321,7 @@ export async function mountCrossView(
|
||||
moveStructure: (structureId, toChainageM) => {
|
||||
structuresPanel.moveStructureTo(structureId, toChainageM);
|
||||
},
|
||||
selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
|
||||
});
|
||||
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
|
||||
const pipeOptionsContext: PipeOptionsContext = {
|
||||
@@ -353,6 +359,11 @@ export async function mountCrossView(
|
||||
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
|
||||
// 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다.
|
||||
if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" });
|
||||
// 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면
|
||||
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
|
||||
const pipeAt = structuresPanel.currentChainage();
|
||||
const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`);
|
||||
if (mark) sectionView.selectMark(mark);
|
||||
structureSelection.syncInletStructure();
|
||||
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
||||
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
|
||||
@@ -426,7 +437,16 @@ export async function mountCrossView(
|
||||
handoff,
|
||||
sectionDetail,
|
||||
{
|
||||
focusStation: (stationId) => sectionView.focusStation(stationId),
|
||||
// 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15).
|
||||
focusStation: (stationId) => {
|
||||
structuresPanel.prefer(handoff?.structureId ?? null);
|
||||
sectionView.focusStation(stationId);
|
||||
structuresPanel.prefer(null);
|
||||
// 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1)
|
||||
const sid = handoff?.structureId;
|
||||
if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid))
|
||||
structuresPanel.selectStructure(sid);
|
||||
},
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
|
||||
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
|
||||
@@ -485,6 +505,8 @@ export async function mountCrossView(
|
||||
// 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다.
|
||||
host.onShow(() => {
|
||||
if (currentRouteId === null) return;
|
||||
// 떠난 사이 종단 보기가 폼을 다른 것으로 바꿨을 수 있음 — 같은 넘김값도 다시 적용(53-1)
|
||||
appliedPick = null;
|
||||
void structuresPanel.load().then(restoreStructurePick);
|
||||
void refreshDetailForStructures().then(() => reconcileStaleDesigns());
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
restoreStructurePick,
|
||||
wireStructurePick,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection";
|
||||
import { NO_SELECTION, PIPE_MATCH_M, nearestWithin, selectDesign } from "./design_selection";
|
||||
import type { DesignPageHost } from "./design_page";
|
||||
import {
|
||||
bindDesignView,
|
||||
@@ -123,6 +123,15 @@ export async function mountProfileView(
|
||||
profilePanel.setMinCoverPipes(pipes);
|
||||
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
|
||||
};
|
||||
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2). */
|
||||
const selectGraphAt = (id: string | null, chainageM: number | null): void => {
|
||||
const bare =
|
||||
id === null && chainageM !== null
|
||||
? lastPipes?.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M)
|
||||
: undefined;
|
||||
if (bare) profilePanel.setSelectedStructure(`pipe-${bare.chainage_m.toFixed(2)}`);
|
||||
else profilePanel.setSelectedStation(id);
|
||||
};
|
||||
// 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b).
|
||||
const profilePanel = createRouteProfilePanel(
|
||||
shared,
|
||||
@@ -205,7 +214,7 @@ export async function mountProfileView(
|
||||
selectionSyncing = value;
|
||||
},
|
||||
selectMarker: (id) => viewer.markers.selectStation(id),
|
||||
selectGraph: (id) => profilePanel.setSelectedStation(id),
|
||||
selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null),
|
||||
selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM),
|
||||
selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM),
|
||||
selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM),
|
||||
@@ -299,10 +308,11 @@ export async function mountProfileView(
|
||||
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
|
||||
const id = station ? irregularStationId(station.id) : null;
|
||||
viewer.markers.selectStation(id);
|
||||
profilePanel.setSelectedStation(id);
|
||||
selectGraphAt(id, chainage);
|
||||
syncBasinHighlight(id);
|
||||
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
|
||||
profilePanel.drainage.selectPipeAtChainage(chainage);
|
||||
viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2)
|
||||
} finally {
|
||||
selectionSyncing = false;
|
||||
}
|
||||
@@ -315,6 +325,7 @@ export async function mountProfileView(
|
||||
selectionSyncing = true;
|
||||
try {
|
||||
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
|
||||
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
|
||||
} finally {
|
||||
selectionSyncing = false;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createGeneralLayout } from "@ui/ui_template_general_layout";
|
||||
import { createHelpButton } from "@ui/ui_template_help";
|
||||
import { isBlank } from "@util/common_util_validate";
|
||||
import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name";
|
||||
@@ -299,7 +300,8 @@ export function renderB02ProjRegister(root: HTMLElement): void {
|
||||
// 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-general-block__actions b02-proj__actions";
|
||||
actions.append(cancelBtn, submitBtn);
|
||||
// 좌측 패널이 없어 [?](54-2)는 아래 단추 줄 맨 앞
|
||||
actions.append(createHelpButton(), cancelBtn, submitBtn);
|
||||
const card = createCard({ body: [grid, actions], raised: true });
|
||||
const layout = createGeneralLayout({
|
||||
pageClass: "b02-proj",
|
||||
|
||||
@@ -1,77 +1,18 @@
|
||||
/* =============================================================================
|
||||
* B03_FileInput_UI_Guide.ts
|
||||
* 파일 입력 좌측 안내 패널 (2026-09-03 사용자 지시).
|
||||
* 파일 입력 좌측 패널 (2026-09-03 사용자 지시).
|
||||
*
|
||||
* 종전에는 고르는 방법·필요한 파일 목록이 **선택 영역과 카드 안에** 길게 들어가 있어
|
||||
* 카드 한 장이 220px씩 차지했다. 안내는 한 번 읽으면 되는 것이라 다른 단계(B04·B05)와
|
||||
* 같은 자리 — 공용 오버레이의 좌측 패널(`createWorkflowOverlays.optionsContent`) — 로
|
||||
* 옮기고, 본문은 고르는 자리만 남긴다.
|
||||
*
|
||||
* 패널 양식은 **B04~B07과 같은 공용 양식**을 그대로 쓴다(2026-09-03 사용자 지시):
|
||||
* 패널 루트 `{page}__form`, 문단은 `ui-collapsible ui-sidebar-section` + 제목에
|
||||
* `ui-collapsible__title`. 제목 행을 누르면 접히는 동작·캐럿·외곽선이 전부 공용 것이다.
|
||||
* 안내 글(필요한 파일 · 고르는 방법 · 알아 둘 것)은 [?] 설명서 `resources/help/B03/*.md` 로
|
||||
* 옮김(54-2). 패널에는 현황 배너와 아래 단추 줄([?] 자리)만 남김.
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
/** 안내 문단 하나 — 공용 접기 컨테이너(B06 `buildGroup`과 같은 조립). */
|
||||
function section(
|
||||
titleKey: keyof typeof ui_locales,
|
||||
itemKeys: (keyof typeof ui_locales)[],
|
||||
): HTMLElement {
|
||||
const group = document.createElement("section");
|
||||
group.className = "b03-file__guide-group ui-collapsible ui-sidebar-section";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.className = "b03-file__guide-legend ui-collapsible__title";
|
||||
title.textContent = L(titleKey);
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "b03-file__guide-list";
|
||||
for (const key of itemKeys) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = L(key);
|
||||
list.append(item);
|
||||
}
|
||||
|
||||
group.append(title, list);
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌측 패널 본문 — 필요한 파일 · 고르는 방법 · 알아 둘 것.
|
||||
*
|
||||
* `statusNote`(재접속 현황 배너)는 「고르는 방법」 끝에 붙는다 — 프로젝트가 이미 완료라
|
||||
* 다시 올리면 교체된다는 안내라서, 고르기 전에 읽을 것들과 같은 자리에 둔다
|
||||
* (2026-09-03 사용자 지시).
|
||||
*/
|
||||
/** 좌측 패널 본문 — `statusNote`(재접속 현황 배너) + 아래 단추 줄(공용 [?] 가 붙음). */
|
||||
export function createInputGuide(statusNote?: HTMLElement): HTMLElement {
|
||||
const guide = document.createElement("div");
|
||||
guide.className = "b03-file__form";
|
||||
const howTo = section("B03_Guide_How_Title", [
|
||||
"B03_Guide_How_Drop",
|
||||
"B03_Guide_How_Card",
|
||||
"B03_Guide_How_Temp",
|
||||
]);
|
||||
if (statusNote) howTo.append(statusNote);
|
||||
guide.append(
|
||||
section("B03_Guide_Files_Title", [
|
||||
"B03_Guide_Files_Route",
|
||||
"B03_Guide_Files_Terrain",
|
||||
"B03_Guide_Files_Optional",
|
||||
]),
|
||||
howTo,
|
||||
section("B03_Guide_Notes_Title", [
|
||||
"B03_Guide_Notes_Replace",
|
||||
"B03_Guide_Notes_Crs",
|
||||
"B03_Guide_Notes_LasFree",
|
||||
]),
|
||||
);
|
||||
attachCollapsible(guide);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
if (statusNote) guide.append(statusNote);
|
||||
guide.append(actions);
|
||||
return guide;
|
||||
}
|
||||
|
||||
@@ -310,10 +310,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 좌측 안내 패널 (2026-09-03) — 본문에서 뺀 설명이 사는 자리.
|
||||
양식은 B04~B07 좌측 패널과 **같은 공용 양식**이다: 루트 `__form`, 문단은
|
||||
`ui-collapsible ui-sidebar-section`(외곽선·접힘·캐럿은 공용 CSS 몫), 제목은
|
||||
`__group-legend` 크기. 여기서는 값만 맞추고 새로 만들지 않는다. --- */
|
||||
/* --- 좌측 패널 — 현황 배너 + 아래 단추 줄(안내 글은 [?] 설명서로 옮김 · 54-2) --- */
|
||||
.b03-file__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -321,36 +318,6 @@
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b03-file__guide-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
margin: 0;
|
||||
padding: var(--spacing-16);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b03-file__guide-legend {
|
||||
margin: 0;
|
||||
padding: 0 var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 글자 크기·색도 공용 양식 그대로 — B04 좌측 패널 본문(`b04-surface__check`)과 같은
|
||||
`--text-body-sm` · `--color-text-body`(2026-09-03 사용자 지시: 양식 = 글자 크기 포함). */
|
||||
.b03-file__guide-list {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b03-file__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,6 +8,7 @@ longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -33,6 +34,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_REVET,
|
||||
PipePoint,
|
||||
load_pipe_points_file,
|
||||
parse_pipe_points,
|
||||
pipe_anchor_clearances,
|
||||
route_signature,
|
||||
)
|
||||
@@ -205,6 +207,56 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P
|
||||
return points
|
||||
|
||||
|
||||
def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None:
|
||||
"""관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음."""
|
||||
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
||||
return None
|
||||
if pipe.facility == PIPE_FACILITY_PIPE:
|
||||
values = pipe.options or {}
|
||||
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
||||
diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM))
|
||||
return f"{kind} D{diameter}"
|
||||
definition = types.get(pipe.facility)
|
||||
return definition.name if definition is not None else None
|
||||
|
||||
|
||||
def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None:
|
||||
"""횡단 측점 라벨을 **지금 관 지점 종류**로 맞춘다(54-4).
|
||||
|
||||
라벨은 측점 생성 때 적혀 파일에 남는다 — 저장으로 종류만 바꾸면(배관 → BOX암거) 측점은
|
||||
다시 안 만들어져 카드 제목이 옛 종류로 남는다. 관 지점 라벨(종류 이름 · 「관종 D관경」)인
|
||||
측점만 고치고 다른 구조물 라벨은 건드리지 않는다.
|
||||
"""
|
||||
path = (
|
||||
project_root
|
||||
/ "B04_PreProcess"
|
||||
/ DRAINAGE_CACHE_DIRNAME
|
||||
/ DRAINAGE_EDITS_DIRNAME
|
||||
/ DRAINAGE_PIPE_POINTS_FILENAME
|
||||
)
|
||||
try:
|
||||
points = parse_pipe_points(json.loads(path.read_text(encoding="utf-8")).get("points"))
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return
|
||||
types = structure_type_map()
|
||||
pipe_names = {definition.name for definition in types.values() if definition.managed_by}
|
||||
for section in cross_sections:
|
||||
label = section.get("structure")
|
||||
if not isinstance(label, str) or not (
|
||||
label in pipe_names or re.fullmatch(r"\S+ D\d+", label)
|
||||
):
|
||||
continue
|
||||
chainage = float(section.get("chainage_m") or 0.0)
|
||||
pipe = next((p for p in points if abs(p.chainage_m - chainage) < 0.02), None)
|
||||
if pipe is None:
|
||||
continue
|
||||
fresh = pipe_station_label(pipe, types)
|
||||
if fresh is None:
|
||||
section.pop("structure", None)
|
||||
else:
|
||||
section["structure"] = fresh
|
||||
|
||||
|
||||
def resolve_extra_stations(
|
||||
project_root: Path, pipes: list[PipePoint]
|
||||
) -> tuple[tuple[float, str], ...]:
|
||||
@@ -224,18 +276,9 @@ def resolve_extra_stations(
|
||||
types = structure_type_map()
|
||||
derived: list[tuple[float, str]] = []
|
||||
for pipe in pipes:
|
||||
if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT:
|
||||
label = pipe_station_label(pipe, types)
|
||||
if label is None:
|
||||
continue
|
||||
if pipe.facility == PIPE_FACILITY_PIPE:
|
||||
values = pipe.options or {}
|
||||
kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE)
|
||||
diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM))
|
||||
label = f"{kind} D{diameter}"
|
||||
else:
|
||||
definition = types.get(pipe.facility)
|
||||
if definition is None:
|
||||
continue
|
||||
label = definition.name
|
||||
# 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 —
|
||||
# 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은
|
||||
# 기준 한 곳(관이 노선을 가로지르는 점형).
|
||||
|
||||
@@ -88,6 +88,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
|
||||
spec.basin_length_m ?? "",
|
||||
spec.basin_before_m ?? "",
|
||||
spec.basin_after_m ?? "",
|
||||
spec.basin_height_m ?? "",
|
||||
].join(",");
|
||||
const basin = section.design?.basin_adjust;
|
||||
const revet = section.design?.revet_adjust;
|
||||
|
||||
@@ -18,11 +18,9 @@
|
||||
* 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로
|
||||
* 자동 채운다. 형식·치수 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다.
|
||||
*
|
||||
* BOX암거(2026-08-17 사용자 확정): 본체는 실무 관측 규격 2.0×2.0·3.0×3.0을 프리셋
|
||||
* 셀렉트로 받고(기본 2.0×2.0), 그 밖의 규격만 "사용자 지정"으로 폭·높이를 직접
|
||||
* 받는다 — 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로다.
|
||||
* 날개벽은 유입·유출이 같은 조각(createWingFields)을 쓰고 설치 있음·짧은쪽 높이
|
||||
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
|
||||
* BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2 · `design_structure_box`) — 본체 규격 ·
|
||||
* 유입 · 유출 구조. 저장 키는 body_width_m·body_height_m 숫자 · wing_in·wing_out 그대로.
|
||||
* 날개벽 조각(createWingFields)은 숨김 — B06 조정 행 자리만 BOX · 세월교 명세 유입 · 유출 칸으로 옮겨 붙음.
|
||||
*
|
||||
* 짝: 시설 속성 붙이기 `carryIndices` ↔ 서버 `common_util/common_util_drainage_pipes.py`
|
||||
* `carry_indices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry.
|
||||
@@ -35,8 +33,6 @@ import type {
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
|
||||
import {
|
||||
BOX_SIZE_CUSTOM,
|
||||
BOX_SIZE_PRESETS,
|
||||
createRevetSideGroup,
|
||||
createWingFields,
|
||||
grid,
|
||||
@@ -44,15 +40,21 @@ import {
|
||||
INLET_REVET_KEYS,
|
||||
type InletStructureKind,
|
||||
labeled,
|
||||
numberInput,
|
||||
optionalSelect,
|
||||
OUTLET_REVET_KEYS,
|
||||
putNumber,
|
||||
WING_IN_KEYS,
|
||||
WING_OUT_KEYS,
|
||||
} from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
|
||||
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
|
||||
import {
|
||||
BOX_SPEC,
|
||||
bodyOf,
|
||||
bodyOptions,
|
||||
boxOptionsIn,
|
||||
} from "../A00_Common/design/design_structure_box";
|
||||
import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford";
|
||||
import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
|
||||
import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc";
|
||||
import {
|
||||
hasLinks,
|
||||
@@ -60,7 +62,7 @@ import {
|
||||
type GroupItems,
|
||||
type SummaryItem,
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
|
||||
import { FORD_PAVEMENT_SPEC } from "../A00_Common/design/design_structure_ford_pavement";
|
||||
import { createHiddenOptions } from "./B05_Profile_UI_Drainage_Facility_Hidden";
|
||||
|
||||
export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로
|
||||
@@ -250,45 +252,31 @@ export function createFacilityOptionsForm(
|
||||
root.className = "b05-drainage__facility";
|
||||
root.hidden = true;
|
||||
|
||||
// ── 관종 · 관경 한 행 — 세월교 몫(배관은 아래 명세 폼 · PLAN 52-1) ─────────
|
||||
const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]);
|
||||
const pipeDiameter = document.createElement("select");
|
||||
pipeDiameter.replaceChildren(
|
||||
new Option("— 안 정함 —", ""),
|
||||
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
|
||||
);
|
||||
const pipeRow = grid(
|
||||
labeled("관종", pipeMaterial, "pipe_kind"),
|
||||
labeled("관경", pipeDiameter, "pipe_diameter_mm"),
|
||||
);
|
||||
|
||||
// ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ──
|
||||
let linkSource: SpecLinks | null = null;
|
||||
let chainage: number | null = null;
|
||||
const pipeForm = createSpecForm(
|
||||
{
|
||||
get: () => linkSource?.get() ?? {},
|
||||
pick: (key) => linkSource?.pick(key),
|
||||
clear: (key) => linkSource?.clear(key),
|
||||
set: (key, item) => linkSource?.set(key, item),
|
||||
const specLinks: SpecLinks = {
|
||||
get: () => linkSource?.get() ?? {},
|
||||
pick: (key) => linkSource?.pick(key),
|
||||
clear: (key) => linkSource?.clear(key),
|
||||
set: (key, item) => linkSource?.set(key, item),
|
||||
};
|
||||
const pipeForm = createSpecForm(specLinks, {
|
||||
auto: (_c, item) => {
|
||||
if (item.id !== "length") return "";
|
||||
const length = pipeLengthM(chainage);
|
||||
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
|
||||
},
|
||||
{
|
||||
auto: (_c, item) => {
|
||||
if (item.id !== "length") return "";
|
||||
const length = pipeLengthM(chainage);
|
||||
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
|
||||
},
|
||||
hint: (c, item, state) => {
|
||||
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
|
||||
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
|
||||
},
|
||||
onChange: (c, item) => {
|
||||
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
|
||||
inletStructureHandler?.(inletStructureOf());
|
||||
emit();
|
||||
},
|
||||
hint: (c, item, state) => {
|
||||
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
|
||||
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
|
||||
},
|
||||
);
|
||||
onChange: (c, item) => {
|
||||
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
|
||||
inletStructureHandler?.(inletStructureOf());
|
||||
emit();
|
||||
},
|
||||
});
|
||||
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4)
|
||||
const inletSlot = pipeForm.slot("inlet");
|
||||
const outletSlot = pipeForm.slot("outlet");
|
||||
@@ -301,6 +289,26 @@ export function createFacilityOptionsForm(
|
||||
: "auto";
|
||||
};
|
||||
|
||||
// ── 세월교 = 사용자 구조물 표 04 명세(PLAN 52-4) — 세월교 · 유입 구조 · 유출 구조 ──
|
||||
// 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`)
|
||||
let fordFlow: number | null = null;
|
||||
const fordNeed = (): number | null =>
|
||||
fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? ""));
|
||||
const fordForm = createSpecForm(specLinks, {
|
||||
hint: (c, item, state) =>
|
||||
c.id === "ford" && item.id === "height"
|
||||
? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? ""))
|
||||
: "",
|
||||
onChange: (c, item) => {
|
||||
const need = c.id === "ford" ? fordNeed() : null;
|
||||
const height = fordForm.state().ford?.[0]?.height ?? "";
|
||||
const low = height !== "" && !(Number(height) >= (need ?? 0));
|
||||
if (need !== null && (item.id === "width" || (item.id === "height" && low)))
|
||||
fordForm.setValue("ford", "height", need.toFixed(2));
|
||||
emit();
|
||||
},
|
||||
});
|
||||
|
||||
// ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자
|
||||
// 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다
|
||||
// (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다).
|
||||
@@ -351,65 +359,33 @@ export function createFacilityOptionsForm(
|
||||
return merged;
|
||||
};
|
||||
|
||||
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
|
||||
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
|
||||
const boxSize = optionalSelect("— 안 정함 —", [
|
||||
...BOX_SIZE_PRESETS.map(([label]) => label),
|
||||
BOX_SIZE_CUSTOM,
|
||||
]);
|
||||
const boxWidth = numberInput("0.1", "0");
|
||||
const boxHeight = numberInput("0.1", "0");
|
||||
const boxCustomRow = grid(
|
||||
labeled("본체 폭 (m)", boxWidth, "body_width_m"),
|
||||
labeled("본체 높이 (m)", boxHeight, "body_height_m"),
|
||||
);
|
||||
const boxWrap = document.createElement("div");
|
||||
boxWrap.append(
|
||||
grid(labeled("본체 규격 (m)", boxSize, ["body_width_m", "body_height_m"])),
|
||||
boxCustomRow,
|
||||
);
|
||||
// ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ──
|
||||
const boxForm = createSpecForm(specLinks, { onChange: () => emit() });
|
||||
// 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음
|
||||
const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS);
|
||||
const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS);
|
||||
const wingInHome = wingInFields.slot.parentElement!;
|
||||
const wingOutHome = wingOutFields.slot.parentElement!;
|
||||
|
||||
const hiddenOptions = createHiddenOptions(root);
|
||||
|
||||
/** 고른 프리셋을 폭·높이 칸에 실어 둔다 — 저장도, "사용자 지정"으로 바꿨을 때의
|
||||
* 출발값도 이 두 칸이 정본이다. 사용자 지정 상태에서는 손대지 않는다. */
|
||||
function syncBoxSize(): void {
|
||||
const preset = BOX_SIZE_PRESETS.find(([label]) => label === boxSize.value);
|
||||
if (preset) {
|
||||
boxWidth.value = String(preset[1]);
|
||||
boxHeight.value = String(preset[2]);
|
||||
}
|
||||
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로.
|
||||
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
|
||||
}
|
||||
// ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도 · 도수로/돌붙임 ·
|
||||
// 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ──
|
||||
const paveForm = createSpecForm(specLinks, { onChange: () => emit() });
|
||||
|
||||
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford`).
|
||||
// 기본값(등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 — 제안값 단추 없음(PLAN 46-7).
|
||||
const ford = createFordFields(() => emit());
|
||||
|
||||
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
|
||||
const saveNote = document.createElement("p");
|
||||
saveNote.className = "b05-structure__owner-note";
|
||||
saveNote.textContent =
|
||||
"[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀";
|
||||
// 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`.
|
||||
|
||||
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
|
||||
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
|
||||
root.append(
|
||||
saveNote,
|
||||
pipeRow,
|
||||
pipeForm.root,
|
||||
ford.widthRow,
|
||||
ford.slopeRow,
|
||||
ford.countRow,
|
||||
ford.summary,
|
||||
fordForm.root,
|
||||
paveForm.root,
|
||||
extraGroup.root,
|
||||
revetTopRow,
|
||||
revetInlet.root,
|
||||
revetOutlet.root,
|
||||
boxWrap,
|
||||
boxForm.root,
|
||||
wingInFields.root,
|
||||
wingOutFields.root,
|
||||
);
|
||||
@@ -421,19 +397,17 @@ export function createFacilityOptionsForm(
|
||||
if (current === null) return;
|
||||
const isPipe = current === "pipe";
|
||||
const isBox = current === "box_culvert";
|
||||
pipeRow.hidden = current !== "ford_bridge";
|
||||
const isBridge = current === "ford_bridge";
|
||||
pipeForm.root.hidden = !isPipe;
|
||||
boxWrap.hidden = !isBox;
|
||||
// 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정).
|
||||
const hasWing = isBox || current === "ford_bridge";
|
||||
wingInFields.root.hidden = !hasWing;
|
||||
wingOutFields.root.hidden = !hasWing;
|
||||
const isFord = current === "ford_pavement" || current === "ford_bridge";
|
||||
ford.countRow.hidden = current !== "ford_bridge";
|
||||
ford.widthRow.hidden = !isFord;
|
||||
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
|
||||
ford.slopeRow.hidden = current !== "ford_pavement";
|
||||
ford.summary.hidden = !isFord;
|
||||
boxForm.root.hidden = !isBox;
|
||||
fordForm.root.hidden = !isBridge;
|
||||
// 날개벽 칸 = 옛 자리 — BOX(52-2) · 세월교(52-4)는 명세 유입 · 유출 구조 컨테이너 · B06 조정 행 자리만 그 컨테이너 끝으로
|
||||
const spec = isBox ? boxForm : isBridge ? fordForm : null;
|
||||
wingInFields.root.hidden = true;
|
||||
wingOutFields.root.hidden = true;
|
||||
(spec ? spec.slot("inlet") : wingInHome).append(wingInFields.slot);
|
||||
(spec ? spec.slot("outlet") : wingOutHome).append(wingOutFields.slot);
|
||||
paveForm.root.hidden = current !== "ford_pavement";
|
||||
const isRevet = current === "revetment";
|
||||
revetTopRow.hidden = !isRevet;
|
||||
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임).
|
||||
@@ -445,12 +419,6 @@ export function createFacilityOptionsForm(
|
||||
// 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고,
|
||||
// 선택이 풀리면 스스로 사라진다.
|
||||
extraGroup.root.hidden = extraSlot.childElementCount === 0;
|
||||
if (isFord) ford.sync();
|
||||
if (isBox) syncBoxSize();
|
||||
if (hasWing) {
|
||||
wingInFields.syncVisibility();
|
||||
wingOutFields.syncVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
@@ -461,16 +429,9 @@ export function createFacilityOptionsForm(
|
||||
revetInlet.fields.onChange(emit);
|
||||
revetOutlet.fields.onChange(emit);
|
||||
revetSide.addEventListener("change", emit);
|
||||
[
|
||||
pipeMaterial,
|
||||
pipeDiameter,
|
||||
boxSize,
|
||||
boxWidth,
|
||||
boxHeight,
|
||||
...wingInFields.inputs,
|
||||
...wingOutFields.inputs,
|
||||
...ford.inputs,
|
||||
].forEach((input) => input.addEventListener("change", emit));
|
||||
[...wingInFields.inputs, ...wingOutFields.inputs].forEach((input) =>
|
||||
input.addEventListener("change", emit),
|
||||
);
|
||||
|
||||
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
|
||||
let inletStructureHandler: ((value: InletStructureKind) => void) | null = null;
|
||||
@@ -500,8 +461,16 @@ export function createFacilityOptionsForm(
|
||||
bindLinks(links) {
|
||||
linkSource = links;
|
||||
pipeForm.paintLinks();
|
||||
boxForm.paintLinks();
|
||||
fordForm.paintLinks();
|
||||
paveForm.paintLinks();
|
||||
},
|
||||
paintLinks: () => {
|
||||
pipeForm.paintLinks();
|
||||
boxForm.paintLinks();
|
||||
fordForm.paintLinks();
|
||||
paveForm.paintLinks();
|
||||
},
|
||||
paintLinks: () => pipeForm.paintLinks(),
|
||||
setChainage(chainageM) {
|
||||
chainage = chainageM;
|
||||
if (current === "pipe") pipeForm.refresh();
|
||||
@@ -515,7 +484,7 @@ export function createFacilityOptionsForm(
|
||||
setFacility(facility, options = {}, designFlow = null, definition = null) {
|
||||
current = facility;
|
||||
hiddenOptions.remember(options);
|
||||
ford.setDesignFlow(designFlow ?? null);
|
||||
fordFlow = designFlow ?? null;
|
||||
if (facility === null) {
|
||||
syncVisibility();
|
||||
return;
|
||||
@@ -523,33 +492,20 @@ export function createFacilityOptionsForm(
|
||||
const text = (key: string): string =>
|
||||
options[key] !== undefined ? String(options[key]) : "";
|
||||
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
|
||||
pipeMaterial.value = text("pipe_kind");
|
||||
pipeDiameter.value = text("pipe_diameter_mm");
|
||||
if (facility === "pipe") {
|
||||
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
|
||||
pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups);
|
||||
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
|
||||
const names = definition?.spec_names ?? {};
|
||||
if (facility === "pipe") pipeForm.write(PIPE_SPEC, options, names, groups);
|
||||
if (facility === "box_culvert") {
|
||||
// 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽
|
||||
boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups);
|
||||
for (const [id, value] of Object.entries(bodyOf(options)))
|
||||
boxForm.setValue("body", id, value);
|
||||
}
|
||||
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
|
||||
// 값이 없으면 「안 정함」.
|
||||
const savedWidth = Number.parseFloat(text("body_width_m"));
|
||||
const savedHeight = Number.parseFloat(text("body_height_m"));
|
||||
const hasSaved =
|
||||
Number.isFinite(savedWidth) &&
|
||||
savedWidth > 0 &&
|
||||
Number.isFinite(savedHeight) &&
|
||||
savedHeight > 0;
|
||||
const preset = hasSaved
|
||||
? BOX_SIZE_PRESETS.find(
|
||||
([, width, height]) =>
|
||||
Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05,
|
||||
)
|
||||
: undefined;
|
||||
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : "";
|
||||
boxWidth.value = hasSaved ? String(savedWidth) : "";
|
||||
boxHeight.value = hasSaved ? String(savedHeight) : "";
|
||||
if (facility === "ford_bridge")
|
||||
fordForm.write(FORD_SPEC, withLegacyGuards(options), names, groups);
|
||||
if (facility === "ford_pavement") paveForm.write(FORD_PAVEMENT_SPEC, options, names, groups);
|
||||
wingInFields.write(options);
|
||||
wingOutFields.write(options);
|
||||
if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options);
|
||||
revetSide.value = text("side");
|
||||
const spread = legacyRevetOptions(options);
|
||||
revetInlet.fields.write(spread);
|
||||
@@ -557,31 +513,22 @@ export function createFacilityOptionsForm(
|
||||
syncVisibility();
|
||||
},
|
||||
setDesignFlow(designFlow) {
|
||||
ford.setDesignFlow(designFlow ?? null);
|
||||
fordFlow = designFlow ?? null;
|
||||
if (current === "ford_bridge") fordForm.refresh(); // 높이 안내
|
||||
syncVisibility();
|
||||
},
|
||||
readOptions() {
|
||||
const options: Record<string, string | number> = {};
|
||||
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
|
||||
const putPipe = (): void => {
|
||||
if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value);
|
||||
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
|
||||
};
|
||||
if (current === "pipe") {
|
||||
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
|
||||
} else if (current === "box_culvert") {
|
||||
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
|
||||
putNumber(options, "body_width_m", boxWidth.value);
|
||||
putNumber(options, "body_height_m", boxHeight.value);
|
||||
wingInFields.read(options);
|
||||
wingOutFields.read(options);
|
||||
// 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2)
|
||||
Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0]));
|
||||
} else if (current === "ford_pavement") {
|
||||
ford.read(current, options);
|
||||
Object.assign(options, paveForm.read()); // 표 03 명세 값 · 옛 월류 · 경사 · 두께 · 길이는 병합이 이어 붙임(52-3)
|
||||
} else if (current === "ford_bridge") {
|
||||
putPipe();
|
||||
ford.read(current, options);
|
||||
wingInFields.read(options);
|
||||
wingOutFields.read(options);
|
||||
Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4)
|
||||
} else if (current === "revetment") {
|
||||
if (revetSide.value) options.side = revetSide.value;
|
||||
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
|
||||
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
|
||||
|
||||
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -245,39 +244,18 @@ export interface RevetSideGroup {
|
||||
slot: HTMLElement;
|
||||
}
|
||||
|
||||
/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호).
|
||||
* ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */
|
||||
function sabangBasisLines(): string[] {
|
||||
const { items, scope } = revetmentSabang;
|
||||
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
|
||||
return [
|
||||
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
|
||||
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
|
||||
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
|
||||
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
|
||||
scope,
|
||||
];
|
||||
}
|
||||
|
||||
export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup {
|
||||
const box = group("기슭막이");
|
||||
const kind = document.createElement("select");
|
||||
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
|
||||
// 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정).
|
||||
const fields = createRevetmentFields(keys, { height: "" });
|
||||
const basis = document.createElement("p");
|
||||
basis.className = "b05-route__note";
|
||||
basis.replaceChildren(
|
||||
...sabangBasisLines().flatMap((line, index) =>
|
||||
index ? [document.createElement("br"), line] : [line],
|
||||
),
|
||||
);
|
||||
// 치수 근거 글(사방기술교본)은 [?] 설명서 `구조물_기슭막이.md` 로 옮김(54-2)
|
||||
const slot = document.createElement("div");
|
||||
slot.className = "b05-structure__adjust-slot";
|
||||
box.body.append(
|
||||
grid(labeled("구조", kind), fields.formField),
|
||||
...fields.rows,
|
||||
basis,
|
||||
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
|
||||
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
|
||||
slot,
|
||||
@@ -461,18 +439,6 @@ export function createBasinSplitFields(length: HTMLInputElement): {
|
||||
};
|
||||
}
|
||||
|
||||
/* ── BOX암거 ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은
|
||||
* "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든
|
||||
* body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야
|
||||
* 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */
|
||||
export const BOX_SIZE_PRESETS: ReadonlyArray<readonly [string, number, number]> = [
|
||||
["2.0×2.0", 2, 2],
|
||||
["3.0×3.0", 3, 3],
|
||||
];
|
||||
export const BOX_SIZE_CUSTOM = "사용자 지정";
|
||||
|
||||
/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */
|
||||
interface WingKeys {
|
||||
install: string;
|
||||
|
||||
@@ -17,12 +17,6 @@
|
||||
|
||||
const revetKeys = (side: "inlet" | "outlet"): string[] =>
|
||||
["form", "length_m", "height_m", "before_m", "after_m"].map((key) => `${side}_revet_${key}`);
|
||||
const wingKeys = (side: "in" | "out"): string[] => [
|
||||
`wing_${side}`,
|
||||
`wing_${side}_height_m`,
|
||||
`wing_${side}_length_m`,
|
||||
`wing_${side}_angle_deg`,
|
||||
];
|
||||
|
||||
/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */
|
||||
export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
@@ -40,16 +34,21 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
"outlet_type",
|
||||
...revetKeys("outlet"),
|
||||
],
|
||||
box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")],
|
||||
ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
|
||||
// BOX = 표 02 명세 키(PLAN 52-2) — 옛 날개벽 치수는 폼에서 빠져 이어 붙임(지난 정의 값)
|
||||
box_culvert: ["body_width_m", "body_height_m", "body_custom", "wing_in", "wing_out"],
|
||||
// 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) — 옛 월류 · 경사 · 두께 · 길이는
|
||||
// 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES`
|
||||
ford_pavement: ["ford_slope_deg"],
|
||||
// 세월교 = 표 04 명세 키(PLAN 52-4 · `design_structure_ford`) — 옛 관종 · 날개벽 치수는 폼에서 빠져 이어 붙임
|
||||
ford_bridge: [
|
||||
"pipe_kind",
|
||||
"pipe_diameter_mm",
|
||||
"pipe_count",
|
||||
"ford_width_m",
|
||||
"ford_height_m",
|
||||
...wingKeys("in"),
|
||||
...wingKeys("out"),
|
||||
"inlet_type",
|
||||
"outlet_type",
|
||||
"wing_in",
|
||||
"wing_out",
|
||||
],
|
||||
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
|
||||
};
|
||||
@@ -57,6 +56,7 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
|
||||
/** 폼이 인스턴스 번호를 붙여 적는 키 머리(명세 선택 컨테이너 · `{머리}{n}_{항목}`) — 빼면 지워짐. */
|
||||
export const FORM_OPTION_PREFIXES: Readonly<Record<string, readonly string[]>> = {
|
||||
pipe: ["soil_add_", "chute_add_"],
|
||||
ford_pavement: ["ford_chute_"],
|
||||
};
|
||||
|
||||
/** 독립 기슭막이 레지스트리 키 → 폼은 유입·유출 두 채널 키로 적는다. */
|
||||
|
||||
@@ -313,15 +313,14 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
|
||||
async function loadStructures(): Promise<void> {
|
||||
try {
|
||||
// 레지스트리는 **한 번만** 받고 쓰는 자리에 따라 나눈다(2026-09-07).
|
||||
// · 추가 메뉴 — 켜진 종류만(B05 는 종전대로 여섯 군).
|
||||
// · 알약 레인·군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 레지스트리에서
|
||||
// 꺼져 있는데, 켜진 것만 주면 이름·색·군을 못 찾아 종단에 「?」 회색 알약으로 뜬다.
|
||||
// · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군).
|
||||
// · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은
|
||||
// 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다.
|
||||
const [allTypes, stored] = await Promise.all([
|
||||
fetchStructureTypes(true),
|
||||
fetchStructures(deps.projectId),
|
||||
]);
|
||||
const menuTypes = allTypes.filter((type) => type.enabled);
|
||||
deps.panel().structures.setTypes(menuTypes);
|
||||
deps.panel().structures.setTypes(allTypes, (type) => type.enabled);
|
||||
deps.profilePanel().setStructureTypes(allTypes);
|
||||
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
|
||||
structureTypeMap = new Map(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./B05_Profile_UI_IrregularStations";
|
||||
import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures";
|
||||
import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel";
|
||||
import { HIDDEN_PICK_GROUPS } from "./B05_Profile_UI_Structures_Panel_Types";
|
||||
|
||||
/** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */
|
||||
export interface StructureMenuType {
|
||||
@@ -32,7 +33,9 @@ export function structureGroupMenuItems(
|
||||
types: ReadonlyArray<StructureMenuType>,
|
||||
onPick: (typeId: string) => void,
|
||||
): MapContextMenuItem[] {
|
||||
const groups = [...new Set(types.map((type) => type.group))];
|
||||
const groups = [...new Set(types.map((type) => type.group))].filter(
|
||||
(group) => !HIDDEN_PICK_GROUPS.has(group),
|
||||
);
|
||||
return groups.map((group) => ({
|
||||
label: GROUP_LABELS[group] ?? group,
|
||||
children: types
|
||||
@@ -142,12 +145,39 @@ export function structureIdAtStation(
|
||||
if (!stationId.startsWith(prefix)) return null;
|
||||
const station = stations.find((entry) => irregularStationId(entry.id) === stationId);
|
||||
if (!station) return null;
|
||||
const hit = structures.find(
|
||||
const near = structures.filter(
|
||||
(item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M,
|
||||
);
|
||||
// 관 측점선의 짝은 관 알약(`pipe-*`)이다 — 같은 측점 다른 구조물(포장 등)을 켜면 그 알약을
|
||||
// 누를 때 해제로 읽혀 안 골라졌다(52-15).
|
||||
const hit = near.find((item) => String(item.structure_id ?? "").startsWith("pipe-")) ?? near[0];
|
||||
return hit?.structure_id ?? null;
|
||||
}
|
||||
|
||||
/** 측점선이 골라질 때 켤 알약 — 지금 고른 알약이 그 측점 자리면 그대로 둔다(같은 측점
|
||||
* 여럿 · 24-6b). 규칙 측점(B06 카드)도 기준점 누가거리로 맞춰 본다 — 안 보면 카드가 켜지며
|
||||
* 고른 구조물 알약이 꺼졌다(53-2). */
|
||||
export function structureIdKeeping(
|
||||
stationId: string | null,
|
||||
selectedId: string | null,
|
||||
stations: IrregularStation[],
|
||||
structures: StructureInstance[],
|
||||
detail: {
|
||||
longitudinal: { stations: ReadonlyArray<{ station_id: string; chainage_m: number }> };
|
||||
} | null,
|
||||
): string | null {
|
||||
const selected = structures.find((item) => item.structure_id === selectedId);
|
||||
const at = detail?.longitudinal.stations.find(
|
||||
(entry) => entry.station_id === stationId,
|
||||
)?.chainage_m;
|
||||
const keeps =
|
||||
stationId !== null &&
|
||||
!!selected &&
|
||||
(stationIdAtStructure(selectedId, stations, structures) === stationId ||
|
||||
(at !== undefined && Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M));
|
||||
return keeps ? selectedId : structureIdAtStation(stationId, stations, structures);
|
||||
}
|
||||
|
||||
/** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */
|
||||
export function stationIdAtStructure(
|
||||
structureId: string | null,
|
||||
|
||||
@@ -23,8 +23,8 @@ export interface SelectionSyncPorts {
|
||||
setSyncing: (value: boolean) => void;
|
||||
/** 3D 뷰어 마커 선택. */
|
||||
selectMarker: (stationId: string | null) => void;
|
||||
/** 종단 그래프 세로선 선택. */
|
||||
selectGraph: (stationId: string | null) => void;
|
||||
/** 종단 그래프 세로선 선택 — 누가거리는 세로선 없는 시설(물넘이포장)의 알약을 켜는 데 씀. */
|
||||
selectGraph: (stationId: string | null, chainageM?: number | null) => void;
|
||||
/** 좌측 구조물 폼 선택(누가거리 기준). */
|
||||
selectSidebar: (chainageM: number | null) => void;
|
||||
/** 배수유역도 세부유역 강조(누가거리 기준). */
|
||||
@@ -98,7 +98,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync {
|
||||
ports.setSyncing(true);
|
||||
try {
|
||||
ports.selectMarker(id);
|
||||
ports.selectGraph(id);
|
||||
ports.selectGraph(id, chainageM);
|
||||
// 투영 측점이 아직 없어도(방금 넣은 임시 관 — 재계산 전) 누가거리는 그대로
|
||||
// 넘긴다. null을 넘기면 사이드가 "해제"로 받아 임시 배치를 스스로 취소한다
|
||||
// (2026-08-18 임시 배치 자기 삭제 버그).
|
||||
|
||||
@@ -88,7 +88,8 @@ export function restoreStructurePick(
|
||||
selectStation(handoff.at);
|
||||
// 구조물 id 가 적혀 있을 때만 구조물로 연다 — 관 목록이 아직 안 실렸을 때 측점만으로 구조물을
|
||||
// 짐작하면 그 측점을 덮는 다른 구조물이 열린다(관은 목록이 실릴 때 위 `selectStation` 이 연다).
|
||||
if (!structures.hasSelection() && handoff.structureId) {
|
||||
// id 가 있으면 측점이 연 관보다 먼저다(resolveSelection ① · 53-1)
|
||||
if (handoff.structureId) {
|
||||
structures.selectAt({ chainageM: handoff.at, structureId: handoff.structureId });
|
||||
}
|
||||
return structures.hasSelection();
|
||||
|
||||
@@ -215,3 +215,25 @@ export function withDefinition<
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 대피로(표 07 · PLAN 52-7) — 옛 대피 종류는 고르기 목록에서 빼고 대피로 + 상세로 읽음.
|
||||
* 옛 기록은 고치기 전까지 옛 종류 그대로(도면 · 집계 그대로) · 고치면 대피로로 저장 · 옛 칸 값은 남음. */
|
||||
export const ROAD_TYPE = "evac_road";
|
||||
export const ROAD_DETAILS: Readonly<Record<string, string>> = {
|
||||
refuge: "대피소",
|
||||
work_yard: "정차/작업장",
|
||||
turnaround: "차돌림곳",
|
||||
};
|
||||
|
||||
/** 고르기 목록에 낼 종류인지 — 옛 대피 종류만 뺌. */
|
||||
export const pickable = (type: { type_id: string }): boolean => !ROAD_DETAILS[type.type_id];
|
||||
|
||||
/** 옛 대피 종류 기록 → 대피로 + 상세(옛 값 그대로 · 상세만 채움). 다른 기록은 그대로. */
|
||||
export function asRoad<T extends { type_id: string; options?: Record<string, string | number> }>(
|
||||
record: T,
|
||||
): T {
|
||||
const detail = ROAD_DETAILS[record.type_id];
|
||||
return detail
|
||||
? { ...record, type_id: ROAD_TYPE, options: { detail, ...record.options } }
|
||||
: record;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ export function createFacilityDefinition(root: HTMLElement): FacilityDefinition
|
||||
// 정의에 새로 든 선택지는 보기를 더하고, 정의 · 규칙 밖 보기는 가림(빈 보기 · 지금 값은 그대로)
|
||||
const have = new Set([...control.options].map((item) => item.value));
|
||||
for (const choice of state.choices)
|
||||
if (!have.has(choice)) control.add(new Option(choice, choice));
|
||||
if (!have.has(choice))
|
||||
control.add(new Option(type.spec_names?.[`${option.key}=${choice}`] ?? choice, choice));
|
||||
for (const item of control.options) {
|
||||
item.hidden =
|
||||
!!item.value && !state.choices.includes(item.value) && item.value !== control.value;
|
||||
@@ -78,7 +79,9 @@ export function createFacilityDefinition(root: HTMLElement): FacilityDefinition
|
||||
root
|
||||
.querySelectorAll(".is-rule-hidden")
|
||||
.forEach((node) => node.classList.remove("is-rule-hidden"));
|
||||
root.querySelectorAll<HTMLOptionElement>("option[hidden]").forEach((o) => (o.hidden = false));
|
||||
root
|
||||
.querySelectorAll<HTMLOptionElement>("option[hidden]:not([data-pick-hidden])")
|
||||
.forEach((o) => (o.hidden = false));
|
||||
paint();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,23 +9,13 @@
|
||||
|
||||
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
|
||||
|
||||
export function field(
|
||||
labelText: string,
|
||||
input: HTMLElement,
|
||||
basis?: string | null,
|
||||
): HTMLLabelElement {
|
||||
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = labelText;
|
||||
// 칸 밑 근거 글(`default_basis`)은 [?] 설명서 `구조물_<종류>.md` 로 옮김(54-2)
|
||||
wrapper.append(caption, input);
|
||||
// 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정).
|
||||
if (basis) {
|
||||
const note = document.createElement("small");
|
||||
note.className = "b05-structure__basis";
|
||||
note.textContent = basis;
|
||||
wrapper.append(note);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -68,6 +58,8 @@ interface OptionShape {
|
||||
export function optionControl(
|
||||
option: OptionShape,
|
||||
stored: string | number | undefined,
|
||||
/** 선택지 보일 글(정의 이름 표 · 52-12) — 값은 그대로 */
|
||||
label: (choice: string) => string = (c) => c,
|
||||
): HTMLInputElement | HTMLSelectElement {
|
||||
const value = stored === undefined || stored === null ? "" : String(stored);
|
||||
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
|
||||
@@ -78,7 +70,7 @@ export function optionControl(
|
||||
const kept = value && !option.choices.includes(value) ? [value] : [];
|
||||
const element = select([
|
||||
["", blank],
|
||||
...[...option.choices, ...kept].map((c) => [c, c] as [string, string]),
|
||||
...[...option.choices, ...kept].map((c) => [c, label(c)] as [string, string]),
|
||||
]);
|
||||
element.value = value;
|
||||
if (tip) element.title = tip;
|
||||
|
||||
@@ -28,14 +28,13 @@ import {
|
||||
createFacilityDefinition,
|
||||
type FacilityDefinition,
|
||||
} from "./B05_Profile_UI_Structures_Define_Form";
|
||||
import { createStructureSpecForm } from "./B05_Profile_UI_Structures_Spec";
|
||||
|
||||
export interface StructuresFormElements {
|
||||
root: HTMLElement;
|
||||
body: HTMLElement;
|
||||
groupSelect: HTMLSelectElement;
|
||||
typeSelect: HTMLSelectElement;
|
||||
/** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */
|
||||
ownerNote: HTMLElement;
|
||||
startFields: StationFields;
|
||||
anchorFields: StationFields;
|
||||
endFields: StationFields;
|
||||
@@ -112,13 +111,6 @@ export function buildStructuresForm(options: {
|
||||
typeRow.className = "b05-structure__grid";
|
||||
typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect));
|
||||
|
||||
// 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라
|
||||
// (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자:
|
||||
// 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다.
|
||||
const ownerNote = document.createElement("p");
|
||||
ownerNote.className = "b05-structure__owner-note";
|
||||
ownerNote.hidden = true;
|
||||
|
||||
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
|
||||
const optionRow = document.createElement("div");
|
||||
optionRow.className = "b05-structure__grid";
|
||||
@@ -164,17 +156,24 @@ export function buildStructuresForm(options: {
|
||||
set: groupBoxes.setOne,
|
||||
});
|
||||
groupBoxes.watch(facilityOptions.paintLinks);
|
||||
// 일반 구조물 명세 컨테이너(PLAN 52-10 · 노면공 등) — 잇기 · 반영 길은 배관 명세와 같음
|
||||
const structureSpec = createStructureSpecForm(
|
||||
optionRow,
|
||||
{ get: groupBoxes.get, pick: groupBoxes.pick, clear: groupBoxes.clear, set: groupBoxes.setOne },
|
||||
options.onFacilityChange,
|
||||
);
|
||||
groupBoxes.watch(structureSpec.paintLinks);
|
||||
const staleBox = document.createElement("fieldset");
|
||||
staleBox.className = "b05-structure__group b05-structure__stale";
|
||||
staleBox.hidden = true;
|
||||
|
||||
body.append(
|
||||
typeRow,
|
||||
ownerNote,
|
||||
positionRow,
|
||||
summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류)
|
||||
positionDivider,
|
||||
optionRow,
|
||||
structureSpec.root,
|
||||
facilityOptions.root,
|
||||
staleBox,
|
||||
groupBoxes.root,
|
||||
@@ -186,7 +185,6 @@ export function buildStructuresForm(options: {
|
||||
body,
|
||||
groupSelect,
|
||||
typeSelect,
|
||||
ownerNote,
|
||||
startFields,
|
||||
anchorFields,
|
||||
endFields,
|
||||
|
||||
@@ -21,7 +21,8 @@ import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row";
|
||||
import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary";
|
||||
import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define";
|
||||
import { field, numberInput } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { numberInput } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { grid, group as boxOf, labeled, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import {
|
||||
GROUP_JOIN,
|
||||
@@ -126,19 +127,20 @@ export function createGroupBoxes(): GroupBoxes {
|
||||
);
|
||||
for (const group of groups) {
|
||||
const keys = hiddenFormKeys(facility, group.options);
|
||||
const box = document.createElement("fieldset");
|
||||
box.className = "b05-structure__group b05-structure__optgroup";
|
||||
box.dataset.b05Group = group.id;
|
||||
const legend = document.createElement("legend");
|
||||
legend.textContent = group.name;
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-structure__grid";
|
||||
// 상자 꼴 = 명세 컨테이너(`design_structure_form`)와 같은 조각 — 제목 · 몸통 · 격자 · 잇기 줄(52-18)
|
||||
const box = boxOf(group.name);
|
||||
box.root.classList.add("b05-structure__optgroup");
|
||||
box.root.dataset.b05Group = group.id;
|
||||
const body = grid();
|
||||
for (const scope of scopes) {
|
||||
const marked = [...scope.querySelectorAll<HTMLElement>("[data-option-keys]")];
|
||||
// 지금 숨은 칸(다른 시설 몫 · 안 고른 갈래)은 그 자리에 둠 — 같은 키 칸이 두 벌 옮겨 오지 않게
|
||||
// 자리 자체가 가려졌으면(안 쓰는 시설 폼) 그 칸도 숨은 것 — 같은 키 칸이 딸려 오지 않게(52-7)
|
||||
const shown = (node: HTMLElement): boolean => {
|
||||
for (let at: HTMLElement | null = node; at && at !== scope; at = at.parentElement)
|
||||
for (let at: HTMLElement | null = node; at; at = at.parentElement) {
|
||||
if (at.hidden) return false;
|
||||
if (at === scope) break;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const fits = marked.filter(
|
||||
@@ -177,23 +179,24 @@ export function createGroupBoxes(): GroupBoxes {
|
||||
handlers?.change?.();
|
||||
});
|
||||
shapeInputs.set(key, input);
|
||||
body.append(field(shape.label, input));
|
||||
body.append(labeled(shape.label, stepper(input, 0.1)));
|
||||
}
|
||||
if (!body.childElementCount) {
|
||||
const none = document.createElement("p");
|
||||
none.className = "b05-structure__owner-note";
|
||||
none.textContent = "옵션 없음 — 집계 항목만 지정";
|
||||
none.textContent = "옵션 없음 — 구조물 조합만 지정";
|
||||
body.append(none);
|
||||
}
|
||||
const row = buildSummaryRow();
|
||||
row.root.querySelector(".b05-structure__summary-label")!.textContent = "구조물 조합";
|
||||
row.set(items[group.id] ?? null);
|
||||
row.bind({
|
||||
pick: () => handlers?.pick(group.id),
|
||||
clear: () => handlers?.clear(group.id),
|
||||
});
|
||||
rows.set(group.id, row);
|
||||
box.append(legend, body, row.root);
|
||||
boxes.push(box);
|
||||
box.body.append(body, row.root);
|
||||
boxes.push(box.root);
|
||||
bodies.push(body);
|
||||
}
|
||||
const stale =
|
||||
|
||||
@@ -56,6 +56,12 @@ export function numberDuplicateNames(names: ReadonlyArray<string>): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름) · 알약도 씀(53-2). */
|
||||
export function etcName(structure: StructureInstance): string | null {
|
||||
const name = structure.type_id === "etc" ? String(structure.options?.name ?? "").trim() : "";
|
||||
return name || null;
|
||||
}
|
||||
|
||||
export function renderStructureList(params: StructureListParams): void {
|
||||
const { list, typeMap, intervalM, editingId, selectedPipeChainage } = params;
|
||||
list.replaceChildren();
|
||||
@@ -89,7 +95,9 @@ export function renderStructureList(params: StructureListParams): void {
|
||||
const displayNames = numberDuplicateNames(
|
||||
rows.map((row) =>
|
||||
row.kind === "structure"
|
||||
? (typeMap.get(row.structure.type_id)?.name ?? row.structure.type_id)
|
||||
? (etcName(row.structure) ??
|
||||
typeMap.get(row.structure.type_id)?.name ??
|
||||
row.structure.type_id)
|
||||
: (typeMap.get(row.pipe.facility)?.name ?? row.pipe.facility),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type StructureInstance,
|
||||
type StructureType,
|
||||
} from "./B05_Profile_Api_Structures";
|
||||
import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
|
||||
import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
|
||||
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
|
||||
@@ -133,7 +133,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
|
||||
(left, right) => structureAnchorM(left) - structureAnchorM(right),
|
||||
);
|
||||
const numberedNames = numberDuplicateNames(
|
||||
anchorSorted.map((entry) => typeMap.get(entry.type_id)?.name ?? entry.type_id),
|
||||
anchorSorted.map(
|
||||
(entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id,
|
||||
),
|
||||
);
|
||||
const displayNames = new Map(
|
||||
anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]),
|
||||
@@ -143,7 +145,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
|
||||
const id = structure.structure_id;
|
||||
if (!id) return;
|
||||
const type = typeMap.get(structure.type_id);
|
||||
const rawName = type?.name ?? structure.type_id;
|
||||
const rawName = etcName(structure) ?? type?.name ?? structure.type_id; // 기타 = 손 이름(목록과 같게)
|
||||
const displayName = displayNames.get(id) ?? rawName;
|
||||
// "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지).
|
||||
const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : "";
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields";
|
||||
import {
|
||||
hasSpecContainers,
|
||||
hidesTypeSummary,
|
||||
paintStale,
|
||||
renderOptionFields as renderOptionFieldsInto,
|
||||
} from "./B05_Profile_UI_Structures_Panel_Options";
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
loadPipeForm as loadPipeFormInto,
|
||||
} from "./B05_Profile_UI_Structures_Panel_Commit";
|
||||
import { renderStructureList } from "./B05_Profile_UI_Structures_List";
|
||||
import { showsAnchor } from "./B05_Profile_UI_Structures_Define";
|
||||
import { pickable, showsAnchor } from "./B05_Profile_UI_Structures_Define";
|
||||
import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection";
|
||||
@@ -53,8 +53,9 @@ export { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types";
|
||||
export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
|
||||
import {
|
||||
DEFAULT_INTERVAL_LENGTH_M,
|
||||
GROUP_LABELS,
|
||||
groupOptions,
|
||||
hasComputedRange,
|
||||
pickOption,
|
||||
readBeforeOption,
|
||||
readLengthOption,
|
||||
type PipeFacilityItem,
|
||||
@@ -77,7 +78,6 @@ export function createStructuresSection(
|
||||
body,
|
||||
groupSelect,
|
||||
typeSelect,
|
||||
ownerNote,
|
||||
startFields,
|
||||
anchorFields,
|
||||
endFields,
|
||||
@@ -164,6 +164,7 @@ export function createStructuresSection(
|
||||
}
|
||||
|
||||
let types: StructureType[] = [];
|
||||
let canPick: (type: StructureType) => boolean = () => true;
|
||||
// 배열 자체를 바꾸지 않는다(조각들이 참조로 들고 있음) — 내용만 갈아끼운다.
|
||||
const structures: StructureInstance[] = [];
|
||||
let pipeFacilities: PipeFacilityItem[] = [];
|
||||
@@ -227,16 +228,12 @@ export function createStructuresSection(
|
||||
!!type && !managed && type.placement === "interval" && !hasComputedRange(type);
|
||||
positionRow.hidden = !type;
|
||||
positionDivider.hidden = !type;
|
||||
summary.root.hidden = !type || hasSpecContainers(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1)
|
||||
summary.root.hidden = !type || hidesTypeSummary(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1)
|
||||
startFields.wrap.hidden = !isInterval;
|
||||
endFields.wrap.hidden = !isInterval;
|
||||
primary.disabled = !type;
|
||||
// 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면
|
||||
// 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자).
|
||||
ownerNote.textContent = type?.design_owner
|
||||
? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.`
|
||||
: "";
|
||||
ownerNote.hidden = !type?.design_owner;
|
||||
// [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김)
|
||||
root.dataset.helpTopic = type ? `구조물_${type.name}` : "";
|
||||
// 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임
|
||||
anchorFields.wrap.hidden = isInterval && !showsAnchor(type!);
|
||||
anchorFields.wrap.querySelector("span")!.textContent = isInterval
|
||||
@@ -324,6 +321,8 @@ export function createStructuresSection(
|
||||
commitTempPipe();
|
||||
syncOptionLock();
|
||||
syncRangeDisplay();
|
||||
// 고르던 항목의 측점도 값 — 다른 칸처럼 바로 반영(53-1)
|
||||
if (positionConfirmed) liveCommit();
|
||||
}
|
||||
|
||||
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */
|
||||
@@ -344,10 +343,10 @@ export function createStructuresSection(
|
||||
* 초기·리셋 상태를 "안 고름"으로 드러낸다. */
|
||||
function syncTypeOptions(keepTypeId?: string): void {
|
||||
const group = groupSelect.value;
|
||||
const candidates = group ? types.filter((type) => type.group === group) : [];
|
||||
const candidates = group ? types.filter((t) => t.group === group && pickable(t)) : [];
|
||||
typeSelect.replaceChildren(
|
||||
new Option("— 선택 —", ""),
|
||||
...candidates.map((type) => new Option(type.name, type.type_id)),
|
||||
...candidates.map((type) => pickOption(type.name, type.type_id, !canPick(type))),
|
||||
);
|
||||
typeSelect.value =
|
||||
keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : "";
|
||||
@@ -538,14 +537,11 @@ export function createStructuresSection(
|
||||
facility: facilityOptions,
|
||||
listRoot: list,
|
||||
hasSelection: () => editingId !== null || selectedPipeChainage !== null,
|
||||
setTypes(next) {
|
||||
setTypes(next, pick = () => true) {
|
||||
types = next;
|
||||
const groups = new Map(next.map((t) => [t.group, t.group_name] as const));
|
||||
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저.
|
||||
groupSelect.replaceChildren(
|
||||
new Option("— 선택 —", ""),
|
||||
...[...groups].map(([g, name]) => new Option(name ?? GROUP_LABELS[g] ?? g, g)),
|
||||
);
|
||||
canPick = pick;
|
||||
// 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다.
|
||||
groupSelect.replaceChildren(new Option("— 선택 —", ""), ...groupOptions(next, pick));
|
||||
groupSelect.value = "";
|
||||
syncTypeOptions();
|
||||
},
|
||||
@@ -557,11 +553,10 @@ export function createStructuresSection(
|
||||
// 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인).
|
||||
structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry })));
|
||||
// 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다.
|
||||
if (applyingLive) renderList();
|
||||
else {
|
||||
loadForm(null);
|
||||
renderList();
|
||||
}
|
||||
// [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2).
|
||||
const kept = structures.find((entry) => entry.structure_id === editingId) ?? null;
|
||||
if (!applyingLive) loadForm(editingId === null ? null : kept);
|
||||
renderList();
|
||||
},
|
||||
getStructures: () => [...structures],
|
||||
setPipeFacilities(pipes) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D
|
||||
import type { StationFields } from "./B05_Profile_UI_Structures_Fields";
|
||||
import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement";
|
||||
import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary";
|
||||
import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures_Spec";
|
||||
import { asRoad } from "./B05_Profile_UI_Structures_Define";
|
||||
import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups";
|
||||
import {
|
||||
readGroupItems,
|
||||
@@ -40,6 +42,8 @@ export interface StructuresCommitContext {
|
||||
startFields: StationFields;
|
||||
endFields: StationFields;
|
||||
facilityOptions: FacilityOptionsForm;
|
||||
/** 옵션 칸 — 짝 명세 폼(52-10)을 찾는 열쇠. */
|
||||
optionRow: HTMLElement;
|
||||
optionInputs: Array<{
|
||||
key: string;
|
||||
required: boolean;
|
||||
@@ -95,16 +99,17 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
|
||||
ctx.setPositionConfirmed(target !== null);
|
||||
const step = ctx.interval();
|
||||
if (target) {
|
||||
const type = ctx.typeMap().get(target.type_id);
|
||||
const shown = asRoad(target); // 옛 대피 종류 = 대피로 + 상세(52-7)
|
||||
const type = ctx.typeMap().get(shown.type_id);
|
||||
if (type) {
|
||||
ctx.groupSelect.value = type.group;
|
||||
ctx.syncTypeOptions(target.type_id);
|
||||
ctx.syncTypeOptions(shown.type_id);
|
||||
}
|
||||
ctx.anchorFields.write(structureAnchorM(target), step);
|
||||
ctx.startFields.write(target.start_m ?? null, step);
|
||||
ctx.endFields.write(target.end_m ?? null, step);
|
||||
ctx.memoField.value = target.memo ?? "";
|
||||
ctx.renderOptionFields(target.options);
|
||||
ctx.renderOptionFields(shown.options);
|
||||
ctx.setSummary(readSummaryItem(target.summary_item));
|
||||
ctx.groupBoxes.set(readGroupItems(target.group_items));
|
||||
ctx.groupBoxes.setShapes(target.options);
|
||||
@@ -128,7 +133,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
|
||||
afterEntry.input.value = after.toFixed(1);
|
||||
}
|
||||
}
|
||||
ctx.syncFacilityForm(target.options);
|
||||
ctx.syncFacilityForm(shown.options);
|
||||
} else {
|
||||
ctx.anchorFields.write(null, step);
|
||||
ctx.startFields.write(null, step);
|
||||
@@ -205,7 +210,7 @@ export function readOptions(ctx: StructuresCommitContext): Record<string, string
|
||||
if (input.isEmpty()) return;
|
||||
values[input.key] = input.read();
|
||||
});
|
||||
return values;
|
||||
return { ...values, ...readStructureSpec(ctx.optionRow, ctx.currentType()) };
|
||||
}
|
||||
|
||||
export function emit(ctx: StructuresCommitContext): void {
|
||||
@@ -367,9 +372,13 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
|
||||
...previous,
|
||||
...records[0],
|
||||
options: mergeStructureOptions(
|
||||
previous,
|
||||
asRoad(previous), // 옛 대피 종류를 대피로로 고쳐도 옛 칸 값은 남김(52-7)
|
||||
type.type_id,
|
||||
[...ctx.optionInputs.map((entry) => entry.key), ...ctx.groupBoxes.shapeKeys()],
|
||||
[
|
||||
...ctx.optionInputs.map((entry) => entry.key),
|
||||
...ctx.groupBoxes.shapeKeys(),
|
||||
...specRenderedKeys(type, previous.options), // 명세 폼 키(52-10) — 비운 칸 · 뺀 컨테이너는 지움
|
||||
],
|
||||
records[0].options,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -50,6 +50,8 @@ export function bindStructuresEvents(ctx: StructuresEventContext): void {
|
||||
// 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시:
|
||||
// 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다).
|
||||
ctx.syncFacilityForm();
|
||||
// 고른 관 지점의 시설 종류를 바꿈(54-4) — 실시간 반영으로 목록 · 알약 · 횡단도가 바로 따라옴
|
||||
if (ctx.selectedPipeChainage() !== null && ctx.currentType()?.managed_by) ctx.liveCommit();
|
||||
if (ctx.editingId()) {
|
||||
ctx.setEditingId(null);
|
||||
ctx.syncButtons();
|
||||
|
||||
@@ -20,13 +20,23 @@ import {
|
||||
staleValues,
|
||||
} from "./B05_Profile_UI_Structures_Define";
|
||||
import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge";
|
||||
import { stepper } from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { specKeys } from "../A00_Common/design/design_structure_spec";
|
||||
import { paintStructureSpec, specOwnedKeys } from "./B05_Profile_UI_Structures_Spec";
|
||||
import { solveSplit, specKeys, type SplitRole } from "../A00_Common/design/design_structure_spec";
|
||||
|
||||
/** 컨테이너(구조물 명세 · 정의 그룹 상자)가 있는 종류인지 — 그때 종류 단위 「구조물 조합」 줄은
|
||||
* 가림(PLAN 52-1 · 52-9 = 컨테이너마다 잇기 · 없으면 종류 단위). */
|
||||
export const hasSpecContainers = (type: StructureType): boolean =>
|
||||
!!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length;
|
||||
/** 종류 단위 「구조물 조합」 줄을 가릴 종류인지 — 구조물 명세 컨테이너 · 정의 그룹 상자(그룹마다 잇기 줄)로
|
||||
* 그리는 종류(PLAN 52-1 · 52-6b · 52-7) · 종단배수(B군 · 조합 잇기 없음 · 표 07). */
|
||||
export const hidesTypeSummary = (type: StructureType): boolean =>
|
||||
!!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length || type.group === "B";
|
||||
|
||||
/** 길이 = 전 + 후 를 배관 맞물림(`solveSplit` · 마지막에 고친 둘을 지킴)으로 푸는 종류(표 07 가드레일). */
|
||||
const SOLVE_SPLIT = new Set(["guardrail"]);
|
||||
const SPLIT_ROLE: Record<string, SplitRole> = {
|
||||
length_m: "length",
|
||||
before_m: "before",
|
||||
after_m: "after",
|
||||
};
|
||||
|
||||
/** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */
|
||||
export interface OptionRenderContext {
|
||||
@@ -62,10 +72,15 @@ export function renderOptionFields(
|
||||
optionRow.replaceChildren();
|
||||
// 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다.
|
||||
// 차례 · 안 씀 · 숨김은 정의대로(48-3 · `_Structures_Define`).
|
||||
// 명세 종류(52-10)는 명세 키 · 명세가 뺀 옛 키를 칸으로 안 그림 — 명세 폼이 옵션 칸 아래에 그림
|
||||
const owned = specOwnedKeys(type);
|
||||
const shown =
|
||||
type && !ctx.facilityFormKind(type)
|
||||
? orderedOptions(type).filter((o) => ctx.includeDetail || isB05Option(o))
|
||||
? orderedOptions(type).filter(
|
||||
(o) => (ctx.includeDetail || isB05Option(o)) && !owned.has(o.key),
|
||||
)
|
||||
: [];
|
||||
paintStructureSpec(optionRow, type, values);
|
||||
optionRow.style.gridTemplateColumns = "";
|
||||
if (!type || !shown.length) {
|
||||
optionRow.hidden = true;
|
||||
@@ -80,9 +95,14 @@ export function renderOptionFields(
|
||||
shown.forEach((option) => {
|
||||
// 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7).
|
||||
// 보기는 정의 전부로 세워 옛 값을 잃지 않고, 숨김 · 규칙 선택지는 `applyRules` 가 좁힘.
|
||||
const input = optionControl(option, values[option.key]);
|
||||
const name = (c: string): string => type.spec_names?.[`${option.key}=${c}`] ?? c;
|
||||
const input = optionControl(option, values[option.key], name);
|
||||
const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
|
||||
const wrap = field(label, input, option.default_basis);
|
||||
// 정의 그룹 상자로 옮겨 갈 숫자 칸 = 명세 컨테이너와 같은 [-][숫자][+](52-18)
|
||||
const grouped =
|
||||
option.input === "number" && !!type.groups?.some((g) => g.options.includes(option.key));
|
||||
const wrap = field(label, grouped ? stepper(input as HTMLInputElement, 0.1) : input);
|
||||
if (grouped) wrap.htmlFor = input.id; // 라벨 대상 = 숫자 칸(첫 버튼으로 새지 않게)
|
||||
wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3)
|
||||
// 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로
|
||||
const place = gridPlace(type.spots?.[option.key], columns);
|
||||
@@ -128,12 +148,31 @@ export function renderOptionFields(
|
||||
"length" | "before_m" | "after_m";
|
||||
const refresh = (normalize: boolean): void => {
|
||||
input.classList.remove("is-invalid");
|
||||
ctx.syncSplitInputs(source, normalize);
|
||||
if (SOLVE_SPLIT.has(type!.type_id)) {
|
||||
solveInto(option.key); // 타이핑 중에도 — change 의 실시간 반영이 푼 값을 읽게
|
||||
ctx.syncRangeDisplay();
|
||||
} else ctx.syncSplitInputs(source, normalize);
|
||||
};
|
||||
input.addEventListener("input", () => refresh(false));
|
||||
input.addEventListener("change", () => refresh(true));
|
||||
}
|
||||
});
|
||||
/** 길이 · 전 · 후 맞물림 — 고친 차례(최근이 앞)를 쥐고 나머지 하나를 셈. */
|
||||
const recent: SplitRole[] = [];
|
||||
function solveInto(key: string): void {
|
||||
const role = SPLIT_ROLE[key];
|
||||
recent.splice(0, recent.length, role, ...recent.filter((r) => r !== role));
|
||||
const inputOf = (r: SplitRole) =>
|
||||
all.find(({ entry }) => SPLIT_ROLE[entry.key] === r)?.entry.input;
|
||||
const values = Object.fromEntries(
|
||||
(["length", "before", "after"] as SplitRole[]).map((r) => [r, inputOf(r)?.value ?? ""]),
|
||||
);
|
||||
const solved = solveSplit(values, recent);
|
||||
for (const r of ["length", "before", "after"] as SplitRole[]) {
|
||||
const input = inputOf(r);
|
||||
if (input) input.value = solved[r] ?? "";
|
||||
}
|
||||
}
|
||||
/** 규칙 걸기 — `parent` 가 바뀐 칸이면 그 칸에 매인 기본값부터 넣음. */
|
||||
function applyRules(parent: string | null): void {
|
||||
const now = (): Record<string, string> =>
|
||||
@@ -152,7 +191,11 @@ export function renderOptionFields(
|
||||
wrap.classList.toggle("is-rule-hidden", off); // 그룹 상자로 옮겨 가도 같은 표식
|
||||
if (!off) optionInputs.push(entry);
|
||||
if (state && entry.input.tagName.toLowerCase() === "select")
|
||||
syncChoices(entry.input as HTMLSelectElement, state.choices);
|
||||
syncChoices(
|
||||
entry.input as HTMLSelectElement,
|
||||
state.choices,
|
||||
(c) => type!.spec_names?.[`${entry.key}=${c}`] ?? c,
|
||||
);
|
||||
}
|
||||
}
|
||||
applyRules(null);
|
||||
@@ -161,14 +204,18 @@ export function renderOptionFields(
|
||||
}
|
||||
|
||||
/** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */
|
||||
function syncChoices(element: HTMLSelectElement, choices: string[]): void {
|
||||
function syncChoices(
|
||||
element: HTMLSelectElement,
|
||||
choices: string[],
|
||||
label: (choice: string) => string,
|
||||
): void {
|
||||
const now = [...element.children].slice(1).map((o) => (o as HTMLOptionElement).value);
|
||||
const keep = element.value && !choices.includes(element.value) ? [element.value] : [];
|
||||
const want = [...choices, ...keep];
|
||||
if (now.length === want.length && now.every((v, i) => v === want[i])) return;
|
||||
const value = element.value;
|
||||
const blank = element.children[0];
|
||||
element.replaceChildren(blank, ...want.map((c) => new Option(c, c)));
|
||||
element.replaceChildren(blank, ...want.map((c) => new Option(label(c), c)));
|
||||
element.value = value;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,33 @@ export const GROUP_LABELS: Record<string, string> = {
|
||||
호환: "기타",
|
||||
};
|
||||
|
||||
/** 새로 놓을 때 고르기 목록(군 드롭다운 · 우클릭 메뉴)에서 뺄 군 — 종단배수·계류사방(표 07).
|
||||
* 옛 값 · 도면 · 집계는 그대로이고 데이터도 지우지 않는다. */
|
||||
export const HIDDEN_PICK_GROUPS: ReadonlySet<string> = new Set(["B", "D"]);
|
||||
|
||||
/** 고르기 목록 항목 — 가릴 항목은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */
|
||||
export function pickOption(text: string, value: string, hidden: boolean): HTMLOptionElement {
|
||||
const option = new Option(text, value);
|
||||
option.hidden = hidden;
|
||||
if (hidden) option.dataset.pickHidden = ""; // 정의 폼의 「가림 풀기」가 건드리지 않게
|
||||
return option;
|
||||
}
|
||||
|
||||
/** 군 드롭다운 항목들 — 이름은 정의(48) 먼저 · 뺄 군과 고를 종류가 없는 군은 가림. */
|
||||
export function groupOptions(
|
||||
types: StructureType[],
|
||||
pick: (type: StructureType) => boolean,
|
||||
): HTMLOptionElement[] {
|
||||
const groups = new Map(types.map((t) => [t.group, t.group_name] as const));
|
||||
return [...groups].map(([g, name]) =>
|
||||
pickOption(
|
||||
name ?? GROUP_LABELS[g] ?? g,
|
||||
g,
|
||||
HIDDEN_PICK_GROUPS.has(g) || !types.some((t) => t.group === g && pick(t)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */
|
||||
export const DEFAULT_INTERVAL_LENGTH_M = 15;
|
||||
|
||||
@@ -83,8 +110,9 @@ export interface StructuresSection {
|
||||
/** 배치된 구조물 목록(<ul>) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다
|
||||
* (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */
|
||||
listRoot: HTMLElement;
|
||||
/** 서버에서 받은 타입 목록을 채운다(최초 1회). */
|
||||
setTypes: (types: StructureType[]) => void;
|
||||
/** 서버에서 받은 타입 목록을 채운다(최초 1회). 이름·폼은 넘긴 종류 전부로 풀고,
|
||||
* 고르기 목록엔 `pick` 이 고른 종류만 보임(빼면 전부). */
|
||||
setTypes: (types: StructureType[], pick?: (type: StructureType) => boolean) => void;
|
||||
/** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */
|
||||
setStructures: (structures: StructureInstance[]) => void;
|
||||
getStructures: () => StructureInstance[];
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Structures_Spec.ts
|
||||
* 일반 구조물(structures.json) 명세 컨테이너 폼(PLAN 52-10) — 계곡 통과 시설 밖 종류가 명세
|
||||
* (`STRUCTURE_SPECS`)를 가지면 옵션 칸 아래에 같은 공용 폼(`design_structure_form`)을 그림.
|
||||
*
|
||||
* · 폼은 옵션 칸(`optionRow`) 짝으로 하나 — 조립기가 세워 걸고, 옵션 그리기 · 저장이 꺼내 씀
|
||||
* · 명세 키 · 명세가 뺀 옛 키(retired)는 정의 옵션 칸으로 그리지 않음(옛 값은 병합이 지킴)
|
||||
* · 잠금은 옵션 칸을 따름(측점 확정 전 흐림 · 조작 막음)
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StructureType } from "./B05_Profile_Api_Structures";
|
||||
import {
|
||||
createSpecForm,
|
||||
type SpecForm,
|
||||
type SpecLinks,
|
||||
} from "../A00_Common/design/design_structure_form";
|
||||
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
|
||||
import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec";
|
||||
|
||||
const forms = new WeakMap<HTMLElement, SpecForm>();
|
||||
|
||||
/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */
|
||||
export const structureSpecOf = (type: StructureType | null): StructureSpec | null =>
|
||||
(type && !type.managed_by && STRUCTURE_SPECS[type.type_id]) || null;
|
||||
|
||||
/** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */
|
||||
export function createStructureSpecForm(
|
||||
optionRow: HTMLElement,
|
||||
links: SpecLinks,
|
||||
onChange: () => void,
|
||||
): SpecForm {
|
||||
const form = createSpecForm(links, { onChange });
|
||||
form.root.hidden = true;
|
||||
forms.set(optionRow, form);
|
||||
const lock = (): void => {
|
||||
const locked = optionRow.classList.contains("is-locked");
|
||||
form.root.inert = locked;
|
||||
form.root.classList.toggle("is-locked", locked);
|
||||
};
|
||||
if (typeof MutationObserver !== "undefined")
|
||||
new MutationObserver(lock).observe(optionRow, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
return form;
|
||||
}
|
||||
|
||||
/** 정의 옵션 칸으로 안 그리는 키 — 명세 키 · 명세가 뺀 옛 키. */
|
||||
export function specOwnedKeys(type: StructureType | null): Set<string> {
|
||||
const spec = structureSpecOf(type);
|
||||
return new Set(spec ? [...specKeys(spec).keys, ...spec.retired] : []);
|
||||
}
|
||||
|
||||
/** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */
|
||||
export function paintStructureSpec(
|
||||
optionRow: HTMLElement,
|
||||
type: StructureType | null,
|
||||
values: Record<string, string | number>,
|
||||
): void {
|
||||
const form = forms.get(optionRow);
|
||||
const spec = structureSpecOf(type);
|
||||
if (!form) return;
|
||||
form.root.hidden = !spec;
|
||||
if (!spec) return;
|
||||
const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name]));
|
||||
form.write(spec, values, type?.spec_names ?? {}, groups);
|
||||
}
|
||||
|
||||
/** 명세 폼 값(저장 키 그대로) — 명세 없는 종류면 빈 묶음. */
|
||||
export function readStructureSpec(
|
||||
optionRow: HTMLElement,
|
||||
type: StructureType | null,
|
||||
): Record<string, string | number> {
|
||||
const form = forms.get(optionRow);
|
||||
return form && structureSpecOf(type) ? form.read() : {};
|
||||
}
|
||||
|
||||
/** [수정] 병합에서 폼이 다시 쓰는 키 — 명세 키 + 옛 값 중 선택 컨테이너 머리 키(빼면 지워져야 함). */
|
||||
export function specRenderedKeys(
|
||||
type: StructureType | null,
|
||||
previous: Record<string, string | number> | undefined,
|
||||
): string[] {
|
||||
const spec = structureSpecOf(type);
|
||||
if (!spec) return [];
|
||||
const { keys, prefixes } = specKeys(spec);
|
||||
const own = Object.keys(previous ?? {}).filter((key) => prefixes.some((p) => key.startsWith(p)));
|
||||
return [...keys, ...own];
|
||||
}
|
||||
@@ -395,6 +395,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-route__irregular-item:hover {
|
||||
border-color: var(--color-royal-amethyst, rgb(109 40 217));
|
||||
}
|
||||
|
||||
.b05-route__irregular-item.is-selected {
|
||||
border-color: var(--color-royal-amethyst, rgb(109 40 217));
|
||||
background: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 12%, transparent);
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
/* 측점 미입력 상태의 상세 옵션 잠금 — 입력 순서 가이드(2026-08-18 사용자 지시).
|
||||
조작 차단은 disabled·inert가 맡고 여기서는 흐림만 그린다. */
|
||||
.b05-structure__grid.is-locked,
|
||||
.b05-structure__spec.is-locked,
|
||||
.b05-drainage__facility.is-locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -214,13 +215,6 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */
|
||||
.b05-structure__basis {
|
||||
color: var(--color-text-muted, #9aa1ad);
|
||||
font-size: var(--text-caption, 12px);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
|
||||
* (2026-08-17 사용자 지시 2). */
|
||||
.b05-structure__position-row {
|
||||
@@ -498,25 +492,13 @@
|
||||
}
|
||||
|
||||
.b05-structure__optgroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0; /* 긴 집계 항목 이름이 상자를 패널 밖으로 밀지 않게(fieldset 최소 폭 · 52-9) */
|
||||
}
|
||||
|
||||
.b05-structure__optgroup > legend {
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.b05-structure__optgroup .is-option-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-structure__optgroup > .b05-structure__grid.is-locked {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 구조물 정의 규칙(PLAN 48-4)이 가린 칸 — 그룹 상자로 옮겨 가도 같은 표식 */
|
||||
.b05-structure-section .is-rule-hidden {
|
||||
display: none;
|
||||
|
||||
@@ -207,6 +207,8 @@ export interface CulvertSideSpec {
|
||||
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
|
||||
basin_before_m?: number | null;
|
||||
basin_after_m?: number | null;
|
||||
/** 집수정 높이(m) — 좌측 폼 「높이」. 없으면 횡단도 자동(I형 노견 접점 · ㄴ·ㄷ형 내공 높이). */
|
||||
basin_height_m?: number | null;
|
||||
/** 기슭막이 전면 기울기(1:n) 종전값 — 그림은 `revetWallSpec().lean`(표준경사 표)을 씀. */
|
||||
face_slope?: number;
|
||||
/** 독립 기슭막이 기울기 판정 칸(저장 원본) — 설치 측 · 성토/절토 · 전면 기울기(B08 과 같은 칸). */
|
||||
|
||||
@@ -185,6 +185,8 @@ def _side_spec(
|
||||
options.get("inlet_basin_after_m"),
|
||||
_number(defaults.get("inlet_basin_after_m"), None),
|
||||
)
|
||||
# 집수정 높이(좌측 폼 · 비면 횡단도 자동) — 횡단도 집수정 벽 높이.
|
||||
spec["basin_height_m"] = _number(options.get("inlet_basin_height_m"), None)
|
||||
return spec
|
||||
|
||||
# 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음).
|
||||
|
||||
@@ -16,6 +16,7 @@ from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_gra
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
prune_stale_cross_files,
|
||||
relabel_pipe_stations,
|
||||
run_section_generation,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
@@ -269,6 +270,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
# 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다.
|
||||
# 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points).
|
||||
attach_culvert_sets(root, cross_sections)
|
||||
relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4)
|
||||
# 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 —
|
||||
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
|
||||
attach_wall_structures(root, cross_sections)
|
||||
|
||||
@@ -22,6 +22,13 @@ import {
|
||||
fetchStructureTypes,
|
||||
readPendingStructures,
|
||||
} from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import {
|
||||
attachCulvertSets,
|
||||
buildCulvertSets,
|
||||
registryDefaults,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import {
|
||||
attachWallSpecs,
|
||||
wallSpecsFrom,
|
||||
@@ -55,6 +62,7 @@ async function withDraftWalls(
|
||||
// ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이
|
||||
// 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도
|
||||
// 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인).
|
||||
await withDraftPipeKinds(detail, projectId);
|
||||
if (!drafts || !drafts.length) return withStructureAreas(detail);
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
const names = new Map(
|
||||
@@ -77,6 +85,66 @@ async function withDraftWalls(
|
||||
return withStructureAreas(detail);
|
||||
}
|
||||
|
||||
/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */
|
||||
const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const;
|
||||
const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] =>
|
||||
facility === "ford_bridge"
|
||||
? "ford"
|
||||
: facility === "box_culvert"
|
||||
? "box"
|
||||
: facility === "ford_pavement"
|
||||
? "ford_pavement"
|
||||
: "culvert";
|
||||
|
||||
/**
|
||||
* 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다.
|
||||
* 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식
|
||||
* (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다.
|
||||
*/
|
||||
async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise<void> {
|
||||
const pipes = readPendingPipes(projectId);
|
||||
if (!pipes?.length) return;
|
||||
const sections = detail.cross_sections as unknown as Array<Record<string, unknown>>;
|
||||
const near = (spec: Record<string, unknown>, section: Record<string, unknown>, at: number) =>
|
||||
Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M;
|
||||
/** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */
|
||||
const fits = (pipe: (typeof pipes)[number], key: string, spec: Record<string, unknown>) =>
|
||||
key === facilityKey(pipe.facility ?? "pipe") &&
|
||||
(key !== "culvert" || (spec.hidden_pipe === true) === (pipe.facility === "revetment"));
|
||||
const stale = pipes.filter((pipe) => {
|
||||
const here = sections.flatMap((section) =>
|
||||
FACILITY_KEYS.flatMap((key) => {
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : [];
|
||||
}),
|
||||
);
|
||||
return here.length > 0 && !here.some(([key, spec]) => fits(pipe, key, spec));
|
||||
});
|
||||
if (!stale.length) return;
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
const registry = registryDefaults(types);
|
||||
for (const section of sections) {
|
||||
for (const key of FACILITY_KEYS) {
|
||||
const spec = section[key] as Record<string, unknown> | undefined;
|
||||
if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key];
|
||||
}
|
||||
// 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 물넘이 = 없음)
|
||||
// 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점(물넘이로 지웠다 되돌린 때)
|
||||
const pipe = stale.find((entry) => {
|
||||
const gap = Math.abs(entry.chainage_m - Number(section.chainage_m));
|
||||
return section.structure ? gap < PIPE_MATCH_M : gap < 0.01;
|
||||
});
|
||||
if (!pipe) continue;
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
const values = { ...registry.pipe, ...pipe.options };
|
||||
if (facility === "ford_pavement") delete section.structure;
|
||||
else if (facility === "pipe")
|
||||
section.structure = `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`;
|
||||
else section.structure = types.find((type) => type.type_id === facility)?.name ?? facility;
|
||||
}
|
||||
attachCulvertSets(sections, buildCulvertSets(stale, registry));
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다.
|
||||
*
|
||||
|
||||
@@ -57,11 +57,9 @@ const SIDE_ROWS = [
|
||||
".b06-structure-panel__struct--wall-height",
|
||||
].join(", ");
|
||||
|
||||
/** 이식 자리가 지금 화면에 서 있는지 — 숨겨진 그룹(다른 시설)이면 옮기지 않는다. */
|
||||
/** 이식 자리가 지금 화면에 서 있는지 — 그 위 어느 줄이든 숨었으면(다른 시설 명세 폼 등) 옮기지 않는다. */
|
||||
function slotUsable(target: HTMLElement | undefined): target is HTMLElement {
|
||||
if (!target) return false;
|
||||
const group = target.closest("fieldset");
|
||||
return !!group && !group.hidden;
|
||||
return !!target?.closest("fieldset") && !target.closest("[hidden]");
|
||||
}
|
||||
|
||||
/** 배관이면 유입구·유출구 칸, 독립 기슭막이면 좌·우 칸, 세월교면 날개벽 칸 —
|
||||
@@ -111,6 +109,13 @@ function clearSideSlots(): void {
|
||||
// 추가 기슭막이 칸은 고른 다단 벽이 있을 때만 서 있는다 — 비면 그룹째 내린다.
|
||||
if (target === sideSlots?.extra) group.hidden = true;
|
||||
}
|
||||
// 칸이 다른 명세로 옮겨 간 뒤 옛 상자에 남은 강조까지 끔(53-1)
|
||||
document
|
||||
.querySelectorAll<HTMLElement>("fieldset.is-active > legend[data-base-label]")
|
||||
.forEach((legend) => {
|
||||
legend.parentElement!.classList.remove("is-active");
|
||||
setGroupLabel(legend.parentElement!, "");
|
||||
});
|
||||
}
|
||||
|
||||
/** 행 DOM을 제자리 표식과 함께 옮긴다 — 숨은 행은 그대로 둔다. */
|
||||
@@ -158,8 +163,8 @@ function relocateSideRows(panelRoot: HTMLElement): void {
|
||||
// 이름표에 지금 만지는 구조물을 적는다 — "유입구 (기슭막이)", "유출구 (기슭막이(독립))".
|
||||
// 추가 기슭막이 칸은 이름 자체가 그 단이라 괄호 안에 단 번호가 온다(2026-08-30 지시 1).
|
||||
setGroupLabel(group, panelRoot.dataset.panelTitle ?? "");
|
||||
// 다단은 칸이 새로 서는 것이라 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2).
|
||||
if (side === "extra") group.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
// 고른 칸이 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2 · 유입 · 유출도 53-1).
|
||||
group.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function ensureDock(): void {
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface BasinBuildInput {
|
||||
* (2026-08-30 사용자 확정: 집수정은 그대로 두고 세월교만 세운다).
|
||||
*/
|
||||
leanRatio?: number;
|
||||
/** 벽 높이(m) — 좌측 폼 집수정 「높이」. 없으면 I형 노견 접점 · ㄴ·ㄷ형 내공 높이. */
|
||||
heightM?: number | null;
|
||||
}
|
||||
|
||||
/** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */
|
||||
@@ -143,10 +145,12 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
|
||||
const memberT = input.memberThicknessM ?? BASIN_MEMBER_THICKNESS_M;
|
||||
const floorThickness = input.floorThicknessM ?? memberT;
|
||||
// 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값).
|
||||
// 폼 「높이」를 적으면 세 형태 모두 그 높이.
|
||||
const wallHeight =
|
||||
shape === "I"
|
||||
input.heightM ??
|
||||
(shape === "I"
|
||||
? Math.max(diameterM + REVET_FREEBOARD_M, edge.elevation_m - anchor.elevation)
|
||||
: adjust.innerHeightM;
|
||||
: adjust.innerHeightM);
|
||||
// 기움은 **수직 기준 반전** — 상단이 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20).
|
||||
// bottomElevation을 내리면 면 기울기를 그대로 연장한다(I형 근입용).
|
||||
const defaultBottom = anchor.elevation - floorThickness;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* B06_Section_UI_Cross_Culvert_Geom.ts
|
||||
* 배수관 세트(배관·기슭막이·성토부) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
|
||||
* 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 기슭막이는 전면
|
||||
* 1:n 평행사변형(n = 품셈 표준경사 · `leanOf`), 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다
|
||||
* 1:n 평행사변형(n = 품셈 표준경사 · `outlineLeanOf`), 관 끝단면은 구조물 변과 평행, 성토 경사선은 벽 접점에서 끊는다
|
||||
* (designTrim). 유입 = 상단측. 집수정은 `_Basin.ts`, 성토부·다단은 `_Extra.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
revetTargetHeight,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { leanOf } from "./B06_Section_UI_Cross_Lean";
|
||||
import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean";
|
||||
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type {
|
||||
BasinLayout,
|
||||
@@ -110,6 +110,8 @@ export function computeCulvertLayout(
|
||||
};
|
||||
const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST;
|
||||
const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin);
|
||||
const basinHeightM =
|
||||
(culvert.inlet.basin_height_m ?? 0) > 0 ? culvert.inlet.basin_height_m : null;
|
||||
|
||||
// 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백).
|
||||
const uphill = section.uphill_side ?? "left";
|
||||
@@ -157,7 +159,7 @@ export function computeCulvertLayout(
|
||||
const basinReason = culvert.hidden_pipe ? null : choiceReason;
|
||||
// ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치 — 바닥(=관 invert)이 따라 오른다.
|
||||
if (basinReason && basinShape !== "I") {
|
||||
inlet.elevation = inletInfo.edge.elevation_m - basinAdjust.innerHeightM;
|
||||
inlet.elevation = inletInfo.edge.elevation_m - (basinHeightM ?? basinAdjust.innerHeightM);
|
||||
} else if (basinReason) {
|
||||
// I형 — 관 시작점(내공 1.0m 자리) invert = 그 x의 원지반(토피 상한 이내).
|
||||
const startOffset =
|
||||
@@ -172,10 +174,10 @@ export function computeCulvertLayout(
|
||||
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
|
||||
const adjInlet = adjustOf(revetShift?.inlet);
|
||||
const adjOutlet = adjustOf(revetShift?.outlet);
|
||||
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5).
|
||||
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 높이) — 형태는 안 무늬만(외곽 같음).
|
||||
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
|
||||
const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
|
||||
const lean = leanOf({
|
||||
const lean = outlineLeanOf({
|
||||
form: wall.form,
|
||||
height_m: wall.pureHeight,
|
||||
section_mode: section.design?.section_mode,
|
||||
@@ -343,6 +345,7 @@ export function computeCulvertLayout(
|
||||
groundAt,
|
||||
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
|
||||
adjust: basinAdjust,
|
||||
heightM: basinHeightM,
|
||||
});
|
||||
basin = built.basin;
|
||||
basinPipeEnd = built.pipeEnd;
|
||||
@@ -624,9 +627,9 @@ export function computeCulvertLayout(
|
||||
)
|
||||
: null;
|
||||
|
||||
// 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈).
|
||||
// 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 높이마다 표준경사(형태 무관 · B08 은 다단을 안 셈).
|
||||
const extraLean = (form: string, pureHeightM: number): number =>
|
||||
leanOf({
|
||||
outlineLeanOf({
|
||||
form,
|
||||
height_m: pureHeightM,
|
||||
section_mode: section.design?.section_mode,
|
||||
|
||||
@@ -11,8 +11,14 @@
|
||||
|
||||
import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope";
|
||||
import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json";
|
||||
import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */
|
||||
export function leanOf(input: WallLeanInput): number {
|
||||
return wallLeanRatio(masonrySlope, input);
|
||||
}
|
||||
|
||||
/** 배관 흙막이 외곽 기울기 — 형태와 무관(형태는 안 무늬만) · 기본 형태의 표준경사. */
|
||||
export function outlineLeanOf(input: WallLeanInput): number {
|
||||
return leanOf({ ...input, form: REVET_FORM_DEFAULT });
|
||||
}
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
* 원천이라 배수관 구간값과 같은 저장기로 되돌려 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { boxSpanM, pipeDiameterM, wingSlabExtendM } from "@util/common_util_culvert_sets";
|
||||
import {
|
||||
boxSpanM,
|
||||
pipeDiameterM,
|
||||
wingInstalled,
|
||||
wingSlabExtendM,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford";
|
||||
@@ -20,6 +25,8 @@ import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
|
||||
/** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */
|
||||
type WingPatch = Partial<{
|
||||
installed: boolean;
|
||||
/** 폼이 낸 설치 글(날개벽 · 흙막이 …) — 있으면 저장에 그 글 그대로(있음/없음으로 안 바꿈). */
|
||||
text: string;
|
||||
height_m: number;
|
||||
length_m: number;
|
||||
angle_deg: number;
|
||||
@@ -29,7 +36,8 @@ type WingPatch = Partial<{
|
||||
function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record<string, number | string> {
|
||||
const prefix = role === "inlet" ? "wing_in" : "wing_out";
|
||||
const options: Record<string, number | string> = {};
|
||||
if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음";
|
||||
if (patch.installed !== undefined)
|
||||
options[prefix] = patch.text ?? (patch.installed ? "있음" : "없음");
|
||||
if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m;
|
||||
if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m;
|
||||
if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg;
|
||||
@@ -76,8 +84,11 @@ function wingPatchFrom(patch: Record<string, number | string>, prefix: string):
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
const wing: WingPatch = {};
|
||||
// 설치 값은 폼이 "있음"/"없음" 문자열로 낸다(백엔드 `_wing_spec`과 같은 규약).
|
||||
if (patch[prefix] !== undefined) wing.installed = patch[prefix] !== "없음";
|
||||
// 설치 값 글(있음 · 없음 · BOX 날개벽 · 흙막이) — 백엔드 `_wing_spec` 과 같은 판정.
|
||||
if (patch[prefix] !== undefined) {
|
||||
wing.installed = wingInstalled(patch[prefix]);
|
||||
wing.text = String(patch[prefix]);
|
||||
}
|
||||
const heightM = num(`${prefix}_height_m`);
|
||||
if (heightM !== undefined) wing.height_m = heightM;
|
||||
const lengthM = num(`${prefix}_length_m`);
|
||||
|
||||
@@ -132,6 +132,7 @@ export function applyPipeOptionsToCache(
|
||||
own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m"));
|
||||
own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m"));
|
||||
own(culvert.inlet, "basin_after_m", num("inlet_basin_after_m"));
|
||||
own(culvert.inlet, "basin_height_m", num("inlet_basin_height_m"));
|
||||
// 연장이 바뀌면 옆 측점 링크도 달라진다 — 그 구조물이 덮는 범위만 다시 그린다.
|
||||
const reach = Math.max(
|
||||
culvert.inlet.revet_before_m ?? 0,
|
||||
|
||||
@@ -38,8 +38,10 @@ import {
|
||||
} from "../common_util/common_util_summary_item";
|
||||
import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
|
||||
import {
|
||||
coversChainage,
|
||||
NO_SELECTION,
|
||||
PIPE_MATCH_M,
|
||||
readSelection,
|
||||
resolveSelection,
|
||||
selectDesign,
|
||||
type DesignSelection,
|
||||
@@ -85,6 +87,8 @@ export interface B06StructuresPanelDeps {
|
||||
stationInterval: () => number;
|
||||
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */
|
||||
focusChainage: (chainageM: number) => void;
|
||||
/** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */
|
||||
markStructure?: (structureId: string | null) => void;
|
||||
/** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */
|
||||
onMarks?: (structures: StructureInstance[], types: StructureType[]) => void;
|
||||
/** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 —
|
||||
@@ -116,6 +120,10 @@ export interface B06StructuresPanel {
|
||||
releasePipe: () => void;
|
||||
/** 그 측점에서 폼이 열 항목을 **열지 않고** 고름 값으로 풀어 준다(세션에 적는 데 쓴다). */
|
||||
selectionAt: (chainageM: number | null) => DesignSelection;
|
||||
/** 다음 카드 고름이 측점보다 먼저 열 구조물 — 알약 · 넘김값이 누른 것(null = 측점 규칙대로). */
|
||||
prefer: (structureId: string | null) => void;
|
||||
/** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */
|
||||
selectStructure: (structureId: string) => void;
|
||||
/** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */
|
||||
getStructures: () => StructureInstance[];
|
||||
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
|
||||
@@ -148,6 +156,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
);
|
||||
|
||||
let markTypes: StructureType[] = [];
|
||||
/** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */
|
||||
let preferredId: string | null = null;
|
||||
/** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */
|
||||
const pushMarks = (): void =>
|
||||
deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes);
|
||||
@@ -162,9 +172,35 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
pushMarks();
|
||||
if (deps.projectId) writePendingStructures(deps.projectId, structures);
|
||||
deps.onStructuresChanged?.();
|
||||
// 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1)
|
||||
const picked = readSelection(deps.projectId);
|
||||
const moved = picked?.structureId
|
||||
? structures.find((entry) => entry.structure_id === picked.structureId)
|
||||
: undefined;
|
||||
if (moved && picked?.chainageM != null && !coversChainage(moved, picked.chainageM)) {
|
||||
const id = moved.structure_id ?? null;
|
||||
preferredId = id;
|
||||
deps.focusChainage(structureAnchorM(moved));
|
||||
preferredId = null;
|
||||
deps.markStructure?.(id); // 카드 고름이 옛 자리로 알약을 풀었으면 다시 세움
|
||||
selectDesign(
|
||||
deps.projectId,
|
||||
{ chainageM: structureAnchorM(moved), structureId: id },
|
||||
{ from: "b06" },
|
||||
);
|
||||
}
|
||||
},
|
||||
onSelect: (structure) => {
|
||||
if (structure) deps.focusChainage(structureAnchorM(structure));
|
||||
if (!structure) {
|
||||
deps.markStructure?.(null); // 풂 = 알약도 끔(53-2)
|
||||
return;
|
||||
}
|
||||
// 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다.
|
||||
preferredId = structure.structure_id ?? null;
|
||||
deps.focusChainage(structureAnchorM(structure));
|
||||
preferredId = null;
|
||||
// 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2)
|
||||
deps.markStructure?.(structure.structure_id ?? null);
|
||||
},
|
||||
getInterval: deps.stationInterval,
|
||||
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
|
||||
@@ -226,18 +262,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
}
|
||||
// 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이
|
||||
// [저장] 때 옛 값으로 되살아난다. 옮기기 전 자리로 찾는다(저장 시점 목록 기준).
|
||||
deps.applyPipeOptions?.(fromChainageM, patch);
|
||||
const hit = pipeFacilities.find(
|
||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
const was = hit?.facility ?? "pipe";
|
||||
const facility = attributes.facility ?? was;
|
||||
// 종류를 바꿈(54-4) — 옛 종류 그림 캐시에 새 종류 칸을 얹지 않음 · 옛 칸은 안 이음
|
||||
if (facility === was) deps.applyPipeOptions?.(fromChainageM, patch);
|
||||
if (hit) {
|
||||
const facility = hit.facility ?? "pipe";
|
||||
hit.options =
|
||||
mergeFacilityOptions({ facility, options: hit.options }, { facility, options: patch })
|
||||
.options ?? {};
|
||||
mergeFacilityOptions(
|
||||
{ facility: was, options: hit.options },
|
||||
{ facility, options: patch },
|
||||
).options ?? {};
|
||||
hit.facility = facility;
|
||||
}
|
||||
persistPipes();
|
||||
section.setPipeFacilities(pipeFacilities);
|
||||
if (facility !== was) {
|
||||
pushMarks();
|
||||
deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`)
|
||||
}
|
||||
},
|
||||
onPipeRemove: (chainageM) => removePipeAt(chainageM),
|
||||
onPipeSelect: (chainageM) => {
|
||||
@@ -248,6 +293,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
{ from: "b06" },
|
||||
);
|
||||
if (chainageM !== null) deps.focusChainage(chainageM);
|
||||
// 알약 = 그 관 알약(같은 측점 구조물 말고 · 측점선 없는 물넘이포장도) — 카드 뒤에(53-2)
|
||||
deps.markStructure?.(chainageM === null ? null : `pipe-${chainageM.toFixed(2)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -464,15 +511,20 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
const selection = { chainageM, structureId: null };
|
||||
return { chainageM, structureId: section.resolveAt(selection).structureId };
|
||||
},
|
||||
prefer: (structureId) => {
|
||||
preferredId = structureId;
|
||||
},
|
||||
selectStructure: (structureId) => section.selectById(structureId),
|
||||
showAtChainage: (chainageM) => {
|
||||
currentChainageM = null;
|
||||
const selection: DesignSelection = { chainageM, structureId: null };
|
||||
const selection: DesignSelection = { chainageM, structureId: preferredId };
|
||||
// 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에
|
||||
// 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳).
|
||||
const hit = resolveSelection(selection, pipeFacilities, structures);
|
||||
if (hit.pipeChainageM !== null) {
|
||||
currentChainageM = hit.pipeChainageM;
|
||||
refreshSpecDefaults(hit.pipeChainageM);
|
||||
deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); // 측점선 없는 물넘이포장도(53-2)
|
||||
}
|
||||
section.selectAt(selection);
|
||||
return { chainageM, structureId: hit.structureId };
|
||||
@@ -534,11 +586,17 @@ export function wireStructureSelection(
|
||||
|
||||
const origRevet = stationControls.revetOffset.select;
|
||||
stationControls.revetOffset.select = (chainageM, key) => {
|
||||
markWallSelecting();
|
||||
// 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1)
|
||||
const nested = wallSelecting;
|
||||
// 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1)
|
||||
if (key) markWallSelecting();
|
||||
origRevet(chainageM, key);
|
||||
if (key) remember?.(ownerChainageOf(chainageM), key);
|
||||
if (key) panel.showAtChainage(ownerChainageOf(chainageM));
|
||||
else panel.releasePipe();
|
||||
else if (!nested) {
|
||||
remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림
|
||||
panel.releasePipe();
|
||||
}
|
||||
if (key) syncInletStructure();
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
@@ -546,22 +604,30 @@ export function wireStructureSelection(
|
||||
|
||||
const origFord = stationControls.ford.select;
|
||||
stationControls.ford.select = (chainageM, role) => {
|
||||
markWallSelecting();
|
||||
const nested = wallSelecting;
|
||||
if (role) markWallSelecting();
|
||||
origFord(chainageM, role);
|
||||
if (role) remember?.(chainageM, role);
|
||||
if (role) panel.showAtChainage(chainageM);
|
||||
else panel.releasePipe();
|
||||
else if (!nested) {
|
||||
remember?.(chainageM, "");
|
||||
panel.releasePipe();
|
||||
}
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다
|
||||
// (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1).
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
const origBox = stationControls.box.select;
|
||||
stationControls.box.select = (chainageM, role) => {
|
||||
markWallSelecting();
|
||||
const nested = wallSelecting;
|
||||
if (role) markWallSelecting();
|
||||
origBox(chainageM, role);
|
||||
if (role) remember?.(chainageM, role);
|
||||
if (role) panel.showAtChainage(chainageM);
|
||||
else panel.releasePipe();
|
||||
else if (!nested) {
|
||||
remember?.(chainageM, "");
|
||||
panel.releasePipe();
|
||||
}
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
|
||||
import type { StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { wingInstalled } from "@util/common_util_culvert_sets";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
|
||||
type Merged = Record<string, string | number>;
|
||||
@@ -50,7 +51,9 @@ function keepWings(
|
||||
for (const [wing, prefix] of wings) {
|
||||
// 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45.
|
||||
const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음";
|
||||
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
|
||||
// 저장 글이 그린 설치와 같은 뜻이면 그대로 둠(BOX 유입·유출 구조 날개벽 · 흙막이 글을 안 덮음)
|
||||
if (stored?.[prefix] === undefined || wingInstalled(stored[prefix]) !== wing.installed)
|
||||
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
|
||||
keepSpec(
|
||||
merged,
|
||||
stored,
|
||||
|
||||
@@ -446,8 +446,15 @@ export function createSectionView(
|
||||
refreshCards,
|
||||
focusStation(stationId) {
|
||||
if (selectedStationId !== stationId) selectStation(stationId, true);
|
||||
else revealCard(stationId, "smooth");
|
||||
else {
|
||||
revealCard(stationId, "smooth");
|
||||
// 같은 카드라도 알약 강조는 다시 맞춘다 — 목록에서 같은 측점 다른 항목을 고른 경우(52-15).
|
||||
profile.showCard(
|
||||
currentDetail?.cross_sections.find((entry) => entry.station_id === stationId) ?? null,
|
||||
);
|
||||
}
|
||||
},
|
||||
selectMark: (structureId) => profile.selectMark(structureId),
|
||||
setStationSelectListener(listener) {
|
||||
stationSelectListener = listener;
|
||||
},
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface SectionStructureEdit {
|
||||
movePipe: (fromChainageM: number, toChainageM: number) => void;
|
||||
/** 구조물(관 아님)을 다른 측점으로 옮긴다. */
|
||||
moveStructure: (structureId: string, toChainageM: number) => void;
|
||||
/** 구조물(관 아님) 하나를 폼에 올리고 그 카드로 간다 — 같은 측점 관보다 먼저(52-15). */
|
||||
selectStructure: (structureId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type DesignPageShared,
|
||||
} from "../A00_Common/design/design_shared";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { coversChainage } from "../A00_Common/design/design_selection";
|
||||
import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import type {
|
||||
CrossSection,
|
||||
@@ -74,6 +75,8 @@ export interface SectionProfile {
|
||||
redraw: () => void;
|
||||
/** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */
|
||||
showCard: (section: CrossSection | null) => void;
|
||||
/** 알약 강조를 그 구조물로(null = 풀기). */
|
||||
selectMark: (structureId: string | null) => void;
|
||||
}
|
||||
|
||||
/** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */
|
||||
@@ -131,6 +134,8 @@ export function createSectionProfile(
|
||||
let profile: DesignProfile | null = null;
|
||||
let detail: SectionDetailResponse | null = null;
|
||||
let marks: ReadonlyArray<StructureInstance> = [];
|
||||
/** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */
|
||||
let markedId: string | null = null;
|
||||
let types: ReadonlyArray<StructureType> = [];
|
||||
let stations: IrregularStation[] = [];
|
||||
let intervalM = 20;
|
||||
@@ -189,9 +194,19 @@ export function createSectionProfile(
|
||||
onIrregularSelect: (station) =>
|
||||
host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null),
|
||||
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
|
||||
// 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15).
|
||||
onStructureSelect: (structureId) => {
|
||||
const hit = marks.find((entry) => entry.structure_id === structureId);
|
||||
if (hit) host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
|
||||
if (!hit) return;
|
||||
const edit = host.edit();
|
||||
if (!isPipeMark(hit) && hit.structure_id && edit) {
|
||||
edit.selectStructure(hit.structure_id);
|
||||
return;
|
||||
}
|
||||
// 목록에서 풀어도 카드는 남음 — 같은 카드면 「다시 보기」만 돼 관이 안 열렸다(53-2)
|
||||
host.selectCard(null);
|
||||
host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null);
|
||||
profile?.setSelectedStructure(structureId); // 측점선 없는 물넘이포장도 그 알약
|
||||
},
|
||||
onStructureMarkMove: (structureId, toChainageM) => {
|
||||
const edit = host.edit();
|
||||
@@ -245,6 +260,10 @@ export function createSectionProfile(
|
||||
redraw() {
|
||||
profile?.redraw();
|
||||
},
|
||||
selectMark(structureId) {
|
||||
markedId = structureId;
|
||||
profile?.setSelectedStructure(structureId);
|
||||
},
|
||||
showCard(section) {
|
||||
if (!profile) return;
|
||||
if (!section) {
|
||||
@@ -255,6 +274,10 @@ export function createSectionProfile(
|
||||
(entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M,
|
||||
);
|
||||
profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id);
|
||||
// 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1)
|
||||
const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined;
|
||||
if (!pipe && marked && coversChainage(marked, section.chainage_m))
|
||||
profile.setSelectedStructure(markedId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface SectionViewController {
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
|
||||
focusStation: (stationId: string) => void;
|
||||
/** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). */
|
||||
selectMark: (structureId: string | null) => void;
|
||||
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
|
||||
* 올린다(2026-08-29 일원화). null = 선택 해제. */
|
||||
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
|
||||
|
||||
@@ -118,10 +118,7 @@ export function buildStandardDiagram(): HTMLElement {
|
||||
text(252, 196, L("B06_Std_Diagram_Fill"), "b06-diag__label-sm", "middle"),
|
||||
);
|
||||
|
||||
// 모식도 설명 글은 [?] 설명서 `B06/표준_횡단면.md` 로 옮김(54-2)
|
||||
wrap.append(svg);
|
||||
const caption = document.createElement("p");
|
||||
caption.className = "b06-std__diagram-caption";
|
||||
caption.textContent = L("B06_Std_Diagram_Caption");
|
||||
wrap.append(caption);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
@@ -276,6 +276,7 @@ export function createStandardPanel(
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-std";
|
||||
root.dataset.helpTopic = "표준_횡단면"; // [?] 설명서 항목(54-2 · 이 상자 안내 글은 그 파일로 옮김)
|
||||
|
||||
// 변수 위치 안내 모식도(고정 도형): 각 설정값이 횡단면 어느 위치인지 표시(작업 C-5).
|
||||
root.append(buildStandardDiagram());
|
||||
@@ -367,9 +368,6 @@ export function createStandardPanel(
|
||||
const legend = document.createElement("summary");
|
||||
legend.className = "b06-std__legend";
|
||||
legend.textContent = L("B06_Std_Detail_Title");
|
||||
const note = document.createElement("p");
|
||||
note.className = "b06-std__note";
|
||||
note.textContent = L("B06_Std_LType_Note");
|
||||
fieldset.append(
|
||||
legend,
|
||||
buildScope("common"),
|
||||
@@ -377,7 +375,6 @@ export function createStandardPanel(
|
||||
buildScope("rock"),
|
||||
buildDivider("B06_Std_Section_PavedOnly"),
|
||||
buildScope("paved"),
|
||||
note,
|
||||
);
|
||||
return fieldset;
|
||||
};
|
||||
|
||||
@@ -173,12 +173,6 @@
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b06-std__diagram-caption {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ─── 다른 프로젝트에서 불러오기 (작업 C-6) ───────────────────────── */
|
||||
.b06-std__loader {
|
||||
display: flex;
|
||||
@@ -307,12 +301,6 @@
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b06-std__note {
|
||||
margin: 0;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.b06-std__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
|
||||
@@ -430,6 +430,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김).
|
||||
const confirmButtonRow = document.createElement("div");
|
||||
confirmButtonRow.className = "b07-drawing-actions__row";
|
||||
confirmButtonRow.dataset.helpRow = "";
|
||||
confirmButtonRow.append(frameEditor.button, confirmButton);
|
||||
confirmActions.append(frameEditor.banner, confirmButtonRow);
|
||||
|
||||
|
||||
@@ -90,8 +90,11 @@ def collect_items(
|
||||
|
||||
한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장, summary_item,
|
||||
복사본}` ·
|
||||
`복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b).
|
||||
`복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b) ·
|
||||
기타는 `그룹` = 사용자가 적은 상세 종류 이름(52-14).
|
||||
"""
|
||||
from M02_MasterTemplete.M02_Table_Fill_Spec import etc_label
|
||||
|
||||
types = types or structure_type_map()
|
||||
lengths = pipe_lengths or {}
|
||||
items: list[dict[str, Any]] = []
|
||||
@@ -100,6 +103,10 @@ def collect_items(
|
||||
definition = types.get(item.type_id)
|
||||
if definition is not None and definition.managed_by:
|
||||
continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈
|
||||
options = _with_defaults(definition, dict(item.options or {}))
|
||||
label = etc_label(item.type_id, item.options)
|
||||
if label: # 표 머리(이름마다 펼침)도 손 이름 · 비면 「기타」(53-2)
|
||||
options["name"] = label["그룹"]
|
||||
items.append(
|
||||
{
|
||||
"type_id": item.type_id,
|
||||
@@ -107,10 +114,11 @@ def collect_items(
|
||||
"chainage_m": float(item.chainage_m),
|
||||
"start_m": item.start_m,
|
||||
"end_m": item.end_m,
|
||||
"options": _with_defaults(definition, dict(item.options or {})),
|
||||
"options": options,
|
||||
"summary_item": item.summary_item,
|
||||
"복사본": item.structure_id or f"#{index}",
|
||||
"group_items": item.group_items,
|
||||
**etc_label(item.type_id, item.options),
|
||||
}
|
||||
)
|
||||
for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))):
|
||||
@@ -138,6 +146,7 @@ def _with_group_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
복사본 `{구조물 id}__{그룹 id}`. 정의된 그룹만(미지정 그룹 = 「미지정」 알림에 셈) · 정의가 사라진
|
||||
그룹 연결은 기록에만 남고 표에 안 들어감."""
|
||||
from common_util.common_util_summary_item import GROUP_JOIN
|
||||
from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows
|
||||
from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of
|
||||
|
||||
defined = groups_of()
|
||||
@@ -154,6 +163,7 @@ def _with_group_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"그룹": group["name"],
|
||||
}
|
||||
)
|
||||
out.extend(container_rows(item, links)) # 선택 컨테이너마다 한 건(PLAN 52-1b)
|
||||
return out
|
||||
|
||||
|
||||
@@ -511,11 +521,6 @@ def fill_document(
|
||||
missing = sum(1 for item in items if item["type_id"] == "pipe" and not item.get("관연장"))
|
||||
if missing:
|
||||
notes.append(f"관 연장 없음 {missing}곳 — B06 횡단 설계 전이면 빈칸")
|
||||
loose = sum(
|
||||
1 for item in items if item["type_id"] == "guardrail" and not item["options"].get("kind")
|
||||
)
|
||||
if loose:
|
||||
notes.append(f"가드레일·경계석·위험표지 종류 미정 {loose}건 — 표에 안 셈")
|
||||
|
||||
# 손 값 지키기 — 같은 줄 id 의 손 열 · 전구간 줄 · 사용자가 더한 줄
|
||||
hand = _hand_ids(columns)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""집계표 채움 — 구조물 명세 선택 컨테이너마다 한 건 더(PLAN 52-1b).
|
||||
|
||||
선택 컨테이너 인스턴스(옵션 `{머리}_{n}_{항목}`)마다 같은 구조물 · 자리에 한 건 —
|
||||
집계 항목 = 잇기 `group_items[{잇기}{n}]` · 복사본 `{구조물 id}__{잇기}{n}` · 옵션 = 그 컨테이너 값.
|
||||
열 바인딩이 읽는 칸이 갈래마다 달라(독립 기슭막이 `form` · `length_m` … · 관 `{유입|유출}_revet_*`)
|
||||
컨테이너 값을 그 칸들에 함께 얹음 — 어느 출처로 읽어도 그 컨테이너 값.
|
||||
짝: `A00_Common/design/design_structure_pipe.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) —
|
||||
시험이 대조.
|
||||
기타(52-14) = 사용자가 적은 상세 종류 이름이 집계 건 이름(`그룹`) · 비면 「기타」.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_summary_item import GROUP_JOIN
|
||||
|
||||
#: 종류 → 선택 컨테이너 (머리, 잇기, 제목)
|
||||
SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = {
|
||||
"pipe": (
|
||||
("soil_add", "gsoiladd", "흙막이 추가"),
|
||||
("chute_add", "gchuteadd", "도수로/돌붙임"),
|
||||
),
|
||||
"ford_pavement": (("ford_chute", "gfordpavechute", "도수로/돌붙임"),),
|
||||
"etc": (("etc_detail", "getcdetail", "상세 내용"),),
|
||||
}
|
||||
_TEXT = ("structure", "form", "note")
|
||||
|
||||
|
||||
def etc_label(type_id: str, options: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""기타 집계 건 이름 `{그룹: 상세 종류 이름}`(비면 「기타」) — 다른 종류는 빈 묶음."""
|
||||
if type_id != "etc":
|
||||
return {}
|
||||
return {"그룹": str((options or {}).get("name") or "").strip() or "기타"}
|
||||
|
||||
|
||||
def _as_options(values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""컨테이너 값 → 열이 읽는 칸 — 구조 = `structure` · `{쪽}_type` ·
|
||||
나머지 = 맨 칸 + `{쪽}_revet_*`."""
|
||||
out: dict[str, Any] = {}
|
||||
for item, value in values.items():
|
||||
if item == "structure":
|
||||
out.update(structure=value, inlet_type=value, outlet_type=value)
|
||||
continue
|
||||
name = item if item in _TEXT else f"{item}_m"
|
||||
out[name] = value
|
||||
for side in ("inlet", "outlet"):
|
||||
out[f"{side}_revet_{name}"] = value
|
||||
return out
|
||||
|
||||
|
||||
def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""한 구조물의 선택 컨테이너 인스턴스마다 한 건(n 차례) — 컨테이너 없는 종류는 빈 목록."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for prefix, link, title in SPEC_CONTAINERS.get(item["type_id"], ()):
|
||||
head = re.compile(rf"^{prefix}_(\d+)_(.+)$")
|
||||
found: dict[int, dict[str, Any]] = {}
|
||||
for key, value in item["options"].items():
|
||||
hit = head.match(key)
|
||||
if hit:
|
||||
found.setdefault(int(hit.group(1)), {})[hit.group(2)] = value
|
||||
for n in sorted(found):
|
||||
gid = f"{link}{n}"
|
||||
rows.append(
|
||||
{
|
||||
**item,
|
||||
"options": {**item["options"], **_as_options(found[n])},
|
||||
"summary_item": links.get(gid),
|
||||
"복사본": f"{item['복사본']}{GROUP_JOIN}{gid}",
|
||||
"그룹": f"{item.get('그룹', title)} {n}",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
@@ -26,10 +26,15 @@ import {
|
||||
} from "./M02_SO_Def_Model";
|
||||
import { buildOptionsBlock } from "./M02_SO_Def_Options";
|
||||
import { buildRulesBlock } from "./M02_SO_Def_Rules";
|
||||
import { buildSpecBlock } from "./M02_SO_Def_Spec";
|
||||
import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe";
|
||||
import { checkBox, textBox } from "./M02_SO_Def_Ui";
|
||||
import { buildGroupBlock } from "./M02_SO_Groups";
|
||||
import { to } from "./M02_SO_Text";
|
||||
|
||||
/** 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) — 모든 종류에서 숨김(52-12 · 정의 화면 = 이름만) */
|
||||
const OLD_EDITOR = false;
|
||||
|
||||
export interface DefScreenOptions {
|
||||
/** 시스템 관리자 — 아니면 읽기만 */
|
||||
isAdmin: boolean;
|
||||
@@ -182,6 +187,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
}
|
||||
const edit = canEdit();
|
||||
const extra = (): NonNullable<StructDef["종류"][string]> => (d.종류[type.type_id] ??= {});
|
||||
// 이름만 고침(PLAN 52-11 · 52-12) — 명세 종류 = 명세 나무 · 나머지 = 정의 그룹 나무
|
||||
const spec = STRUCTURE_SPECS[type.type_id];
|
||||
detail.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-sod__kindhead",
|
||||
@@ -196,16 +203,20 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
edit,
|
||||
attr: "data-sod-kind-off",
|
||||
}),
|
||||
checkBox(
|
||||
to("BaseStationShow"),
|
||||
baseShown(d, type.type_id),
|
||||
(v) => {
|
||||
if (v) delete extra().기준측점;
|
||||
else extra().기준측점 = false;
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-base" },
|
||||
),
|
||||
...(!OLD_EDITOR
|
||||
? []
|
||||
: [
|
||||
checkBox(
|
||||
to("BaseStationShow"),
|
||||
baseShown(d, type.type_id),
|
||||
(v) => {
|
||||
if (v) delete extra().기준측점;
|
||||
else extra().기준측점 = false;
|
||||
changed();
|
||||
},
|
||||
{ edit, attr: "data-sod-base" },
|
||||
),
|
||||
]),
|
||||
...(d.종류[type.type_id]?.동작 ?? []).map((a) => {
|
||||
const tag = createTag(a, "neutral");
|
||||
tag.title = d.동작[a] ?? "";
|
||||
@@ -213,10 +224,14 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
buildOptionsBlock({ def: d, type, edit, open, changed }),
|
||||
buildLayoutBlock({ def: d, type, edit, changed }),
|
||||
buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }),
|
||||
buildRulesBlock({ def: d, type, edit, changed }),
|
||||
...(!OLD_EDITOR
|
||||
? [buildSpecBlock({ def: d, type, spec, edit, changed })]
|
||||
: [
|
||||
buildOptionsBlock({ def: d, type, edit, open, changed }),
|
||||
buildLayoutBlock({ def: d, type, edit, changed }),
|
||||
buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }),
|
||||
buildRulesBlock({ def: d, type, edit, changed }),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,8 @@ export interface StructDef {
|
||||
구조물군: Record<string, string>;
|
||||
동작: Record<string, string>;
|
||||
types: TypeDef[];
|
||||
종류: Record<string, { 기준측점?: boolean; 동작?: string[] }>;
|
||||
/** 이름 = 구조물 명세 이름 바꿈 표(PLAN 52 · 키 `{컨테이너}[.{항목}[={값}]]`) */
|
||||
종류: Record<string, { 기준측점?: boolean; 동작?: string[]; 이름?: Record<string, string> }>;
|
||||
표시: Record<string, Record<string, false>>;
|
||||
그룹: Record<string, GroupDef[]>;
|
||||
배치: Record<string, Record<string, Spot>>;
|
||||
@@ -257,6 +258,10 @@ export function problems(def: StructDef): string[] {
|
||||
if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`);
|
||||
}
|
||||
const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름));
|
||||
const spec = Object.entries(def.종류[t.type_id]?.이름 ?? {})
|
||||
.filter(([, v]) => !v.trim() || v.length > NAME_MAX)
|
||||
.map(([k]) => k);
|
||||
if (spec.length) out.push(`${t.name} 명세 이름: ${spec.join(", ")}`);
|
||||
if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`);
|
||||
const lap = overlaps(placeAll(t.options, def.배치[t.type_id]));
|
||||
if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Spec.ts
|
||||
* 구조물 명세가 있는 종류(PLAN 52 · `STRUCTURE_SPECS`)의 이름 고치기 칸(52-11) — 나무 꼴
|
||||
* 컨테이너 제목 › 항목 이름 › 값 이름 · 이름만 고침 · 키 · 필수/선택 · 꼴은 읽기만 ·
|
||||
* 아래 미리보기 = B05 · B06 이 그리는 같은 명세 폼(`createSpecForm`)에 고친 이름을 얹음.
|
||||
* 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목 › 칸 이름 › 선택지 이름 · 미리보기 = B05 칸 부품
|
||||
* (`field` · `optionControl` · 그룹 상자)으로 같은 차례.
|
||||
* 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createTag, el } from "@ui/ui_template_elements";
|
||||
import { createSpecForm } from "../../A00_Common/design/design_structure_form";
|
||||
import type { StructureSpec } from "../../A00_Common/design/design_structure_spec";
|
||||
import { group } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
|
||||
import { field, optionControl } from "../../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
import type { StructDef, TypeDef } from "./M02_SO_Def_Model";
|
||||
import {
|
||||
groupNameRows,
|
||||
setSpecName,
|
||||
specNameRows,
|
||||
type SpecNameRow,
|
||||
} from "./M02_SO_Def_Spec_Model";
|
||||
import { head, textBox } from "./M02_SO_Def_Ui";
|
||||
import { to, type OptionsTextKey } from "./M02_SO_Text";
|
||||
|
||||
const TAG: Record<NonNullable<SpecNameRow["tag"]>, OptionsTextKey> = {
|
||||
required: "SpecRequired",
|
||||
optional: "SpecOptional",
|
||||
select: "FormSelect",
|
||||
number: "FormNumber",
|
||||
text: "FormText",
|
||||
auto: "FormAuto",
|
||||
bool: "FormBool",
|
||||
};
|
||||
|
||||
/** 정의 그룹 꼴 미리보기 — 그룹 밖 칸 · 그룹 상자(제목 = 그룹 이름) · 선택지 = 이름 표 글(값 그대로). */
|
||||
function groupPreview(def: StructDef, type: TypeDef): HTMLElement {
|
||||
const names = def.종류[type.type_id]?.이름 ?? {};
|
||||
const options = type.options.filter((o) => o.enabled !== false);
|
||||
const cell = (o: (typeof options)[number]): HTMLElement =>
|
||||
field(
|
||||
o.unit ? `${o.label} (${o.unit})` : o.label,
|
||||
optionControl(
|
||||
{
|
||||
input: o.input === "select" || o.input === "number" ? o.input : "text",
|
||||
choices: o.choices ?? [],
|
||||
default: null,
|
||||
required: !!o.required,
|
||||
},
|
||||
undefined,
|
||||
(c) => names[`${o.key}=${c}`] ?? c,
|
||||
),
|
||||
);
|
||||
const groups = def.그룹[type.type_id] ?? [];
|
||||
const inGroup = new Set(groups.flatMap((g) => g.옵션));
|
||||
const grid = (cells: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "b05-structure__grid", children: cells });
|
||||
return el("div", {
|
||||
children: [
|
||||
grid(options.filter((o) => !inGroup.has(o.key)).map(cell)),
|
||||
...groups.map((g) => {
|
||||
const box = group(g.이름);
|
||||
box.body.append(grid(options.filter((o) => g.옵션.includes(o.key)).map(cell)));
|
||||
return box.root;
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSpecBlock(opt: {
|
||||
def: StructDef;
|
||||
type: TypeDef;
|
||||
/** 없으면 정의 그룹 꼴(52-12) */
|
||||
spec: StructureSpec | undefined;
|
||||
edit: boolean;
|
||||
changed: () => void;
|
||||
}): HTMLElement {
|
||||
const { def, type, spec, edit, changed } = opt;
|
||||
const rows = (spec ? specNameRows(def, spec) : groupNameRows(def, type)).map((row) =>
|
||||
el("div", {
|
||||
className: `m02-sod__specrow is-level-${row.level}`,
|
||||
attrs: { "data-sod-spec": row.nameKey },
|
||||
children: [
|
||||
row.nameKey
|
||||
? textBox(row.label, (v) => setSpecName(def, type.type_id, row, v), changed, {
|
||||
edit,
|
||||
attr: "data-sod-spec-name",
|
||||
cls: "m02-sod__name",
|
||||
})
|
||||
: el("span", { text: to("SpecLoose") }),
|
||||
el("small", { className: "m02-opts__key", text: row.key }),
|
||||
...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
// 미리보기 — 값 없는 새 구조물 폼 · 잇기 줄 없음(조합은 프로젝트 몫)
|
||||
let preview: HTMLElement;
|
||||
if (spec) {
|
||||
const form = createSpecForm(null);
|
||||
const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름]));
|
||||
form.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups);
|
||||
preview = form.root;
|
||||
} else preview = groupPreview(def, type);
|
||||
preview.inert = true;
|
||||
return el("div", {
|
||||
className: "m02-sod__block",
|
||||
attrs: { "data-sod-spec-block": "" },
|
||||
children: [
|
||||
...head(to("SpecHead"), to("SpecNote")),
|
||||
...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: to("SpecNone") })]),
|
||||
...head(to("SpecPreview")),
|
||||
el("div", {
|
||||
className: "m02-sod__specpreview",
|
||||
attrs: { "data-sod-spec-preview": "" },
|
||||
children: [preview],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* =============================================================================
|
||||
* M02_SO_Def_Spec_Model.ts
|
||||
* 구조물 명세(PLAN 52 · `design_structure_spec`) 이름 고치기(52-11)의 자료 손질 — 화면 그리기 없음.
|
||||
*
|
||||
* · 명세 = 구조 정본(키 · 컨테이너 · 필수/선택 · 꼴) — 여기서는 **이름만** 고침
|
||||
* · 이름 자리 = 정의 `종류.{종류}.이름`(키 `{컨테이너}` · `{컨테이너}.{항목}` · `{컨테이너}.{항목}={값}`)
|
||||
* · 필수 컨테이너 제목 = 이름 표에 없으면 정의 그룹(`그룹.{종류}` 의 `id = link`) 이름 — B05 그리기
|
||||
* (`containerTitle`)와 같은 차례로 읽고 그 자리에 씀
|
||||
* · 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목(`그룹.이름`) › 칸 이름(옵션 `label`) ›
|
||||
* 선택지 이름(이름 표 키 `{옵션}={값}` · 값은 그대로) — 그룹 밖 칸은 맨 앞 머리 줄 아래
|
||||
* import 는 꼴만 — 시험이 이 파일만 옮겨 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StructureSpec } from "../../A00_Common/design/design_structure_spec";
|
||||
import type { StructDef, TypeDef } from "./M02_SO_Def_Model";
|
||||
|
||||
export interface SpecNameRow {
|
||||
/** 0 = 컨테이너 · 1 = 항목 · 2 = 값 */
|
||||
level: 0 | 1 | 2;
|
||||
/** 이름 표 키 — 빈 글 = 고칠 이름 없는 머리 줄(그룹 밖 칸) */
|
||||
nameKey: string;
|
||||
/** 읽기만 표시 — 컨테이너 id · 항목 id · 값 */
|
||||
key: string;
|
||||
/** 지금 이름 */
|
||||
label: string;
|
||||
/** 읽기만 표시 — 필수/선택 · 꼴 */
|
||||
tag: "required" | "optional" | "select" | "number" | "auto" | "text" | "bool" | null;
|
||||
/** 필수 컨테이너 제목이 정의 그룹 이름에서 옴 — 고치면 그 그룹 이름에 씀 */
|
||||
group: string | null;
|
||||
/** 정의 옵션 이름(`label`)에서 옴 — 고치면 그 옵션 이름에 씀 */
|
||||
option: string | null;
|
||||
}
|
||||
|
||||
/** 명세 차례대로 이름 줄 — 같은 이름 키(갈래마다 겹친 값)는 한 줄. */
|
||||
export function specNameRows(def: StructDef, spec: StructureSpec): SpecNameRow[] {
|
||||
const names = def.종류[spec.typeId]?.이름 ?? {};
|
||||
const groups = def.그룹[spec.typeId] ?? [];
|
||||
const rows: SpecNameRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (row: SpecNameRow): void => {
|
||||
if (seen.has(row.nameKey)) return;
|
||||
seen.add(row.nameKey);
|
||||
rows.push(row);
|
||||
};
|
||||
for (const c of spec.containers) {
|
||||
const group =
|
||||
c.required && names[c.id] === undefined ? groups.find((g) => g.id === c.link) : undefined;
|
||||
push({
|
||||
level: 0,
|
||||
nameKey: c.id,
|
||||
key: c.id,
|
||||
label: names[c.id] ?? group?.이름 ?? c.title,
|
||||
tag: c.required ? "required" : "optional",
|
||||
group: group ? group.id : null,
|
||||
option: null,
|
||||
});
|
||||
for (const item of c.items) {
|
||||
const itemKey = `${c.id}.${item.id}`;
|
||||
push({
|
||||
level: 1,
|
||||
nameKey: itemKey,
|
||||
key: item.id,
|
||||
label: names[itemKey] ?? item.label,
|
||||
tag: item.kind,
|
||||
group: null,
|
||||
option: null,
|
||||
});
|
||||
const choices = [
|
||||
...(item.choices ?? []),
|
||||
...Object.values(item.by?.map ?? {}).flatMap((b) => b.choices ?? []),
|
||||
];
|
||||
for (const ch of choices) {
|
||||
const choiceKey = `${itemKey}=${ch.value}`;
|
||||
push({
|
||||
level: 2,
|
||||
nameKey: choiceKey,
|
||||
key: ch.value,
|
||||
label: names[choiceKey] ?? ch.label,
|
||||
tag: null,
|
||||
group: null,
|
||||
option: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 정의 그룹 꼴 종류(명세 없음) 이름 줄 — 그룹 밖 칸 · 그룹마다 제목 › 칸 › 선택지(B05 그리기 차례). */
|
||||
export function groupNameRows(def: StructDef, type: TypeDef): SpecNameRow[] {
|
||||
const names = def.종류[type.type_id]?.이름 ?? {};
|
||||
const options = type.options.filter((o) => o.enabled !== false);
|
||||
const groups = def.그룹[type.type_id] ?? [];
|
||||
const inGroup = new Set(groups.flatMap((g) => g.옵션));
|
||||
const rows: SpecNameRow[] = [];
|
||||
const optionRows = (keys: readonly string[]): void => {
|
||||
for (const o of options.filter((x) => keys.includes(x.key))) {
|
||||
rows.push({
|
||||
level: 1,
|
||||
nameKey: o.key,
|
||||
key: o.key,
|
||||
label: o.label,
|
||||
tag: o.input,
|
||||
group: null,
|
||||
option: o.key,
|
||||
});
|
||||
for (const ch of o.input === "select" ? (o.choices ?? []) : []) {
|
||||
const nameKey = `${o.key}=${ch}`;
|
||||
rows.push({
|
||||
level: 2,
|
||||
nameKey,
|
||||
key: ch,
|
||||
label: names[nameKey] ?? ch,
|
||||
tag: null,
|
||||
group: null,
|
||||
option: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
const loose = options.filter((o) => !inGroup.has(o.key)).map((o) => o.key);
|
||||
if (loose.length) {
|
||||
rows.push({ level: 0, nameKey: "", key: "", label: "", tag: null, group: null, option: null });
|
||||
optionRows(loose);
|
||||
}
|
||||
for (const g of groups) {
|
||||
rows.push({
|
||||
level: 0,
|
||||
nameKey: `#${g.id}`,
|
||||
key: g.id,
|
||||
label: g.이름,
|
||||
tag: null,
|
||||
group: g.id,
|
||||
option: null,
|
||||
});
|
||||
optionRows(g.옵션);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 이름 하나를 사본에 씀 — 그룹에서 온 제목은 그 그룹 이름 · 옵션 칸은 옵션 이름 · 나머지는 이름 표. */
|
||||
export function setSpecName(def: StructDef, typeId: string, row: SpecNameRow, value: string): void {
|
||||
const option = row.option
|
||||
? def.types.find((t) => t.type_id === typeId)?.options.find((o) => o.key === row.option)
|
||||
: undefined;
|
||||
if (option) {
|
||||
option.label = value;
|
||||
return;
|
||||
}
|
||||
const group = row.group ? def.그룹[typeId]?.find((g) => g.id === row.group) : undefined;
|
||||
if (group) {
|
||||
group.이름 = value;
|
||||
return;
|
||||
}
|
||||
const kind = (def.종류[typeId] ??= {});
|
||||
(kind.이름 ??= {})[row.nameKey] = value;
|
||||
}
|
||||
@@ -332,3 +332,28 @@
|
||||
margin: 0;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* 구조물 명세 이름 고치기(PLAN 52-11) — 컨테이너 › 항목 › 값 들여쓰기 */
|
||||
.m02-sod__specrow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-sod__specrow.is-level-0 {
|
||||
margin-top: var(--spacing-8);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m02-sod__specrow.is-level-1 {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.m02-sod__specrow.is-level-2 {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
.m02-sod__specpreview {
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,17 @@ const TEXT = {
|
||||
FormNumber: ["숫자", "Number"],
|
||||
FormText: ["글", "Text"],
|
||||
FormBool: ["예/아니오", "Yes/No"],
|
||||
FormAuto: ["자동", "Auto"],
|
||||
SpecHead: ["구조물 명세 이름", "Structure spec names"],
|
||||
SpecNote: [
|
||||
"제목 › 칸 이름 › 선택지 이름만 고침 — 키(작은 글) · 필수/선택 · 꼴 · 칸 더하기 · 빼기는 읽기만 · [저장]하면 B05 · B06 이 이 이름으로 그림",
|
||||
"Rename titles › fields › choices only — keys, required/optional, form and adding/removing fields are read-only",
|
||||
],
|
||||
SpecLoose: ["그룹 밖 칸", "Fields outside groups"],
|
||||
SpecNone: ["고칠 이름 없음 — 칸이 없는 종류", "No names to edit — this kind has no fields"],
|
||||
SpecRequired: ["필수", "Required"],
|
||||
SpecOptional: ["선택", "Optional"],
|
||||
SpecPreview: ["미리보기 — B05 · B06 폼", "Preview — B05 · B06 form"],
|
||||
Yes: ["예", "Yes"],
|
||||
No: ["아니오", "No"],
|
||||
Unit: ["단위", "Unit"],
|
||||
|
||||
@@ -108,6 +108,7 @@ function sideSpec(
|
||||
num(defaults.inlet_basin_before_m, null),
|
||||
);
|
||||
spec.basin_after_m = num(options.inlet_basin_after_m, num(defaults.inlet_basin_after_m, null));
|
||||
spec.basin_height_m = num(options.inlet_basin_height_m, null);
|
||||
return spec;
|
||||
}
|
||||
|
||||
@@ -231,6 +232,12 @@ export function wingSlabExtendM(
|
||||
* 날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량.
|
||||
* 연장량 = 길이 × cos(각도) — 각도는 관축(계류 방향) 기준 벌어짐각이다.
|
||||
*/
|
||||
/** 날개벽을 세우는 설치 값인가 — BOX암거 유입·유출 구조(표 02) 없음 · 날개벽 · 흙막이 · 옛 있음 = 날개벽.
|
||||
* 날개벽만 세움 · 빈 값은 세움(기본). 파이썬 `_wing_spec` 과 짝. */
|
||||
export function wingInstalled(value: unknown): boolean {
|
||||
return !["없음", "흙막이"].includes(String(value ?? "").trim());
|
||||
}
|
||||
|
||||
function wingSpec(
|
||||
values: Record<string, unknown>,
|
||||
defaults: Record<string, unknown>,
|
||||
@@ -241,8 +248,7 @@ function wingSpec(
|
||||
const length = num(values[`${prefix}_length_m`], num(defaults[`${prefix}_length_m`], 0.0));
|
||||
const angle = num(values[`${prefix}_angle_deg`], num(defaults[`${prefix}_angle_deg`], 45.0));
|
||||
const height = num(values[`${prefix}_height_m`], num(defaults[`${prefix}_height_m`], 0.0));
|
||||
// BOX암거 유입·유출 구조(표 02) — 없음 · 날개벽 · 흙막이 · 옛 값 있음 = 날개벽. 날개벽만 세움.
|
||||
const installed = !["없음", "흙막이"].includes(String(install ?? "").trim());
|
||||
const installed = wingInstalled(install);
|
||||
return {
|
||||
installed,
|
||||
height_m: height,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""[?] 설명서 글(54-2) — `resources/help/<페이지>/*.md` 를 부를 때마다 읽어 줌.
|
||||
|
||||
사용자가 메모장으로 파일을 고치면 화면 고침 없이 다음에 열 때 바뀐 글이 보임.
|
||||
파일 이름 순서 = 모달 왼쪽 차례 순서 · 제목 = 첫 `# ` 줄(없으면 파일 이름).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter(prefix="/api/help", tags=["help"])
|
||||
|
||||
HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help"
|
||||
PAGE_RE = re.compile(r"^B0[2-9]$")
|
||||
|
||||
|
||||
def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]:
|
||||
if not PAGE_RE.match(page):
|
||||
raise HTTPException(status_code=404, detail="없는 페이지")
|
||||
folder = root / page
|
||||
items = []
|
||||
for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
head = next((line[2:].strip() for line in text.splitlines() if line.startswith("# ")), "")
|
||||
items.append({"key": path.stem, "title": head or path.stem, "text": text})
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{page}")
|
||||
async def get_help(page: str) -> dict[str, list[dict[str, str]]]:
|
||||
return {"items": read_help(page)}
|
||||
@@ -0,0 +1,9 @@
|
||||
# 프로젝트 등록 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 파일입력 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,5 @@
|
||||
# 필요한 파일
|
||||
|
||||
- 계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.
|
||||
- 지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.
|
||||
- 지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 고르는 방법
|
||||
|
||||
- 여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.
|
||||
- 카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.
|
||||
- [임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.
|
||||
@@ -0,0 +1,5 @@
|
||||
# 알아 둘 것
|
||||
|
||||
- 이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.
|
||||
- 작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.
|
||||
- 라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.
|
||||
@@ -0,0 +1,9 @@
|
||||
# 전처리 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 종단설계 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · BOX암거
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,13 @@
|
||||
# 구조물 · 기슭막이
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
|
||||
## 치수 근거
|
||||
|
||||
- 높이 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 설계자 입력 · 비우면 미확정(금액에 안 들어감)
|
||||
- 계획비탈 1:0.3~0.5(사방기술교본 2-나:129 · 3-가:151) — 제안값은 품셈 표준경사
|
||||
- 둑마루 두께 0.3~0.5m(사방기술교본 2-나:143 · 3-가:201 · 3-가:219(콘크리트기슭막이) · 2-나:135(콘크리트 0.3m)) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식
|
||||
- 뒷채움 조약돌 두께 — 사방기술교본 2-나:133(「사방품셈 규정에 의하여」) → 품셈 13-4-4 [주]⑨ 원문 L7164~7171
|
||||
- ⚠ 사방(계류) 교본 기준 — 임도 배수관 유입·유출부 적용은 확인 남음
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 돌쌓기(메)
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 돌쌓기(찰)
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 물넘이포장
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 배관
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 비탈면 녹화
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **공법**(횡단설계) — 「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)
|
||||
@@ -0,0 +1,5 @@
|
||||
# 구조물 · 세월교
|
||||
|
||||
## 저장
|
||||
|
||||
- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 옹벽
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **높이** — 기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
@@ -0,0 +1,3 @@
|
||||
# 구조물 · 측구(옆도랑)
|
||||
|
||||
- 횡단 설계에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.
|
||||
@@ -0,0 +1,6 @@
|
||||
# 구조물 · 큰돌쌓기
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜
|
||||
@@ -0,0 +1,9 @@
|
||||
# 구조물 · 흙막이
|
||||
|
||||
## 칸 기본값의 뜻
|
||||
|
||||
- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식
|
||||
- **단 수(다단)** — 1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)
|
||||
- **기준 올림(사면 위로)** — 0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)
|
||||
- **기준 좌우 이동** — 0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)
|
||||
- **조달**(횡단설계) — 「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음
|
||||
@@ -0,0 +1,9 @@
|
||||
# 횡단설계 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 표준 횡단면 설정
|
||||
|
||||
## 변수 위치 안내
|
||||
|
||||
- 표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).
|
||||
|
||||
## 표준횡단면 상세값
|
||||
|
||||
- L형 측구는 각 횡단면도에서 선택합니다.
|
||||
@@ -0,0 +1,9 @@
|
||||
# 상세설계 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 수량산출 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -0,0 +1,9 @@
|
||||
# 원가계산 설명서
|
||||
|
||||
## 이 화면에서 하는 일
|
||||
|
||||
## 작업 순서
|
||||
|
||||
## 단추
|
||||
|
||||
## 주의할 점
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"판": 7,
|
||||
"판": 12,
|
||||
"schema_version": 1,
|
||||
"comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.",
|
||||
"구조물군": {
|
||||
@@ -382,13 +382,14 @@
|
||||
},
|
||||
{
|
||||
"key": "wing_in",
|
||||
"label": "날개벽(유입)",
|
||||
"label": "유입 구조",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"있음",
|
||||
"없음"
|
||||
"없음",
|
||||
"날개벽",
|
||||
"흙막이"
|
||||
],
|
||||
"default": "있음",
|
||||
"default": "날개벽",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -417,13 +418,14 @@
|
||||
},
|
||||
{
|
||||
"key": "wing_out",
|
||||
"label": "날개벽(유출)",
|
||||
"label": "유출 구조",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"있음",
|
||||
"없음"
|
||||
"없음",
|
||||
"날개벽",
|
||||
"흙막이"
|
||||
],
|
||||
"default": "있음",
|
||||
"default": "날개벽",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
@@ -676,28 +678,41 @@
|
||||
"cross_section",
|
||||
"quantity"
|
||||
],
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"type_id": "cross_underdrain",
|
||||
"group": "A",
|
||||
"name": "맹암거",
|
||||
"placement": "point",
|
||||
"style": {
|
||||
"color": "#3aa0a8",
|
||||
"abbr": "횡맹"
|
||||
},
|
||||
"drawing_views": [
|
||||
"plan",
|
||||
"profile",
|
||||
"cross_section",
|
||||
"quantity"
|
||||
],
|
||||
"options": [
|
||||
{
|
||||
"key": "ditch_spec",
|
||||
"label": "규격",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"콘크리트 개거 150×200",
|
||||
"L형수로 H=0.2"
|
||||
],
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
"label": "연장",
|
||||
"key": "culvert_height_m",
|
||||
"label": "암거 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": null,
|
||||
"required": false,
|
||||
"empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)"
|
||||
"empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)"
|
||||
},
|
||||
{
|
||||
"key": "culvert_slope_pct",
|
||||
"label": "암거 경사",
|
||||
"input": "number",
|
||||
"unit": "%",
|
||||
"default": null,
|
||||
"required": false,
|
||||
"empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2633,6 +2648,36 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type_id": "evac_road",
|
||||
"group": "E",
|
||||
"name": "대피로",
|
||||
"placement": "point",
|
||||
"style": {
|
||||
"color": "#2fa84f",
|
||||
"abbr": "대피로"
|
||||
},
|
||||
"drawing_views": [
|
||||
"plan",
|
||||
"quantity"
|
||||
],
|
||||
"options": [
|
||||
{
|
||||
"key": "detail",
|
||||
"label": "상세",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"대피소",
|
||||
"정차/작업장",
|
||||
"차돌림곳"
|
||||
],
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "b05",
|
||||
"empty_means": "비우면 상세 미정 — 대피로 자리만 잡음(대피소 · 정차/작업장 · 차돌림곳은 뒤에 고름)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type_id": "refuge",
|
||||
"group": "E",
|
||||
@@ -2732,26 +2777,40 @@
|
||||
"drawing_views": [
|
||||
"plan"
|
||||
],
|
||||
"options": [
|
||||
{
|
||||
"key": "facing",
|
||||
"label": "방향",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"좌",
|
||||
"우",
|
||||
"양면"
|
||||
],
|
||||
"default": null,
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
}
|
||||
]
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"type_id": "curb",
|
||||
"group": "E",
|
||||
"name": "경계석",
|
||||
"placement": "point",
|
||||
"style": {
|
||||
"color": "#4fae5c",
|
||||
"abbr": "경계석"
|
||||
},
|
||||
"drawing_views": [
|
||||
"plan"
|
||||
],
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"type_id": "hazard_sign",
|
||||
"group": "E",
|
||||
"name": "위험표시",
|
||||
"placement": "point",
|
||||
"style": {
|
||||
"color": "#5fb83a",
|
||||
"abbr": "위험"
|
||||
},
|
||||
"drawing_views": [
|
||||
"plan"
|
||||
],
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"type_id": "guardrail",
|
||||
"group": "E",
|
||||
"name": "가드레일·경계석·위험표지",
|
||||
"name": "가드레일",
|
||||
"placement": "interval",
|
||||
"style": {
|
||||
"color": "#1f9e46",
|
||||
@@ -2764,17 +2823,35 @@
|
||||
],
|
||||
"options": [
|
||||
{
|
||||
"key": "kind",
|
||||
"label": "종류",
|
||||
"input": "select",
|
||||
"choices": [
|
||||
"가드레일",
|
||||
"경계석",
|
||||
"위험표지"
|
||||
],
|
||||
"key": "form",
|
||||
"label": "형태",
|
||||
"input": "text",
|
||||
"default": null,
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
"label": "길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": null,
|
||||
"empty_means": "비우면 범위를 못 셈 — 길이를 적어야 기준 측점 앞뒤로 구간이 섬"
|
||||
},
|
||||
{
|
||||
"key": "before_m",
|
||||
"label": "기준측점 전",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": null,
|
||||
"empty_means": "비우면 0 — 기준 측점이 곧 시작(길이 = 전 + 후 맞물림)"
|
||||
},
|
||||
{
|
||||
"key": "after_m",
|
||||
"label": "기준측점 후",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": null,
|
||||
"empty_means": "비우면 길이 − 전 으로 셈(길이 = 전 + 후 맞물림)"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2793,6 +2870,20 @@
|
||||
],
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"type_id": "water_intake",
|
||||
"group": "E",
|
||||
"name": "취수정",
|
||||
"placement": "point",
|
||||
"style": {
|
||||
"color": "#3a9e78",
|
||||
"abbr": "취수정"
|
||||
},
|
||||
"drawing_views": [
|
||||
"plan"
|
||||
],
|
||||
"options": []
|
||||
},
|
||||
{
|
||||
"type_id": "barrier_gate",
|
||||
"group": "E",
|
||||
@@ -3170,7 +3261,7 @@
|
||||
"options": [
|
||||
{
|
||||
"key": "name",
|
||||
"label": "이름",
|
||||
"label": "상세 종류 이름",
|
||||
"input": "text",
|
||||
"default": "기타 구조물"
|
||||
}
|
||||
@@ -3246,7 +3337,23 @@
|
||||
"계곡통과시설",
|
||||
"BOX_본체규격",
|
||||
"날개벽"
|
||||
]
|
||||
],
|
||||
"이름": {
|
||||
"body.size": "본체 규격",
|
||||
"body.size=2.0×2.0": "2.0 × 2.0",
|
||||
"body.size=3.0×3.0": "3.0 × 3.0",
|
||||
"body.size=사용자 입력": "사용자 입력",
|
||||
"body.width": "가로 (m)",
|
||||
"body.height": "세로 (m)",
|
||||
"inlet.structure": "구조",
|
||||
"inlet.structure=없음": "없음",
|
||||
"inlet.structure=날개벽": "날개벽",
|
||||
"inlet.structure=흙막이": "흙막이",
|
||||
"outlet.structure": "구조",
|
||||
"outlet.structure=없음": "없음",
|
||||
"outlet.structure=날개벽": "날개벽",
|
||||
"outlet.structure=흙막이": "흙막이"
|
||||
}
|
||||
},
|
||||
"ford_pavement": {
|
||||
"동작": [
|
||||
@@ -3259,7 +3366,25 @@
|
||||
"계곡통과시설",
|
||||
"월류단면",
|
||||
"날개벽"
|
||||
]
|
||||
],
|
||||
"이름": {
|
||||
"ford.diameter": "관경",
|
||||
"ford.diameter=800": "800",
|
||||
"ford.diameter=1000": "1000",
|
||||
"ford.diameter=1200": "1200",
|
||||
"ford.diameter=1500": "1500",
|
||||
"ford.width": "월류 폭 (m)",
|
||||
"ford.height": "월류 높이 (m)",
|
||||
"ford.count": "수량 (련)",
|
||||
"inlet.structure": "구조",
|
||||
"inlet.structure=없음": "없음",
|
||||
"inlet.structure=날개벽": "날개벽",
|
||||
"inlet.structure=흙막이": "흙막이",
|
||||
"outlet.structure": "구조",
|
||||
"outlet.structure=없음": "없음",
|
||||
"outlet.structure=날개벽": "날개벽",
|
||||
"outlet.structure=흙막이": "흙막이"
|
||||
}
|
||||
},
|
||||
"revetment": {
|
||||
"동작": [
|
||||
@@ -3304,6 +3429,40 @@
|
||||
"형상": {}
|
||||
}
|
||||
],
|
||||
"evac_road": [
|
||||
{
|
||||
"id": "gevacdetail",
|
||||
"이름": "상세 내용",
|
||||
"옵션": [
|
||||
"detail"
|
||||
],
|
||||
"형상": {}
|
||||
}
|
||||
],
|
||||
"guardrail": [
|
||||
{
|
||||
"id": "gguarddetail",
|
||||
"이름": "상세 내용",
|
||||
"옵션": [
|
||||
"form",
|
||||
"length_m",
|
||||
"before_m",
|
||||
"after_m"
|
||||
],
|
||||
"형상": {}
|
||||
}
|
||||
],
|
||||
"cross_underdrain": [
|
||||
{
|
||||
"id": "gcrossud",
|
||||
"이름": "맹암거",
|
||||
"옵션": [
|
||||
"culvert_height_m",
|
||||
"culvert_slope_pct"
|
||||
],
|
||||
"형상": {}
|
||||
}
|
||||
],
|
||||
"spoil_bank": [
|
||||
{
|
||||
"id": "gspoildetail",
|
||||
@@ -3323,6 +3482,41 @@
|
||||
],
|
||||
"형상": {}
|
||||
}
|
||||
],
|
||||
"box_culvert": [
|
||||
{
|
||||
"id": "gboxbody",
|
||||
"이름": "본체",
|
||||
"옵션": [
|
||||
"body_width_m",
|
||||
"body_height_m"
|
||||
],
|
||||
"형상": {}
|
||||
},
|
||||
{
|
||||
"id": "gboxin",
|
||||
"이름": "유입 구조",
|
||||
"옵션": [
|
||||
"wing_in"
|
||||
],
|
||||
"형상": {}
|
||||
},
|
||||
{
|
||||
"id": "gboxout",
|
||||
"이름": "유출 구조",
|
||||
"옵션": [
|
||||
"wing_out"
|
||||
],
|
||||
"형상": {}
|
||||
}
|
||||
],
|
||||
"ford_pavement": [
|
||||
{
|
||||
"id": "gfordpaveslope",
|
||||
"이름": "경사각도",
|
||||
"옵션": [],
|
||||
"형상": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"배치": {},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user