Merge remote-tracking branch 'origin/dev' into sub_desktop_1

This commit is contained in:
2026-10-06 10:42:31 +09:00
29 changed files with 4574 additions and 103 deletions
+30 -9
View File
@@ -55,6 +55,9 @@ export interface SpecFormHooks {
/** 숫자 칸 빈 칸 안내. */
hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string;
onChange?: (c: SpecContainer, item: SpecItem) => void;
/** 선택 컨테이너 개수를 밖에서 정함(54-1 배관 흙막이 = 추가 기슭막이(단)) — 수면 그만큼 늘 보임 ·
* [추가] · [빼기] 없음 · 줄면 끝에서부터 빠짐 · null = 모름(저장된 것 그대로) · undefined = 손 [추가]. */
count?: (c: SpecContainer) => number | null | undefined;
}
export interface SpecForm {
@@ -75,6 +78,8 @@ export interface SpecForm {
/** 잇기 값이 바뀌었을 때 줄을 다시 그림. */
paintLinks: () => void;
refresh: () => void;
/** 밖에서 정한 개수(`hooks.count`)가 바뀌었을 때 — 맞춰 그리고 바뀌었으면 change 알림. */
recount: () => void;
}
export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {}): SpecForm {
@@ -122,11 +127,12 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
}
const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? "");
if (item.kind === "auto") {
input.type = "text";
input.readOnly = true;
input.value = hooks.auto?.(c, item, state) || "—";
input.title = "자동 계산 — 고치지 않음";
return input;
// 보여 주는 칸(54-1) — 손 입력 칸 아님
const shown = document.createElement("output");
shown.className = "b05-structure__auto";
shown.textContent = hooks.auto?.(c, item, state) || "—";
shown.title = "자동 계산";
return shown;
}
input.value = values[item.id] ?? "";
return stepper(input, item.step ?? 0.1);
@@ -198,7 +204,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
rows.push({ key, set: row.set });
box.body.append(row.root);
}
if (!c.required) {
if (!c.required && hooks.count?.(c) === undefined) {
const remove = document.createElement("button");
remove.type = "button";
remove.className = "b05-route__irregular-btn";
@@ -222,13 +228,25 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return box.root;
};
function paint(): void {
/** 밖에서 정한 개수로 맞춤 — 줄면 끝 인스턴스 · 잇기를 뺌. 바뀌었으면 true. */
const fit = (c: SpecContainer, list: Instance[]): boolean => {
const n = hooks.count?.(c);
if (c.required || n === undefined || n === null || n === list.length) return false;
for (let k = n + 1; k <= list.length; k++) links?.set(linkOf(c, k), null);
list.splice(n);
while (list.length < n) list.push({});
return true;
};
function paint(quiet = true): void {
rows.length = 0;
const boxes: HTMLElement[] = [];
const fitted: SpecContainer[] = [];
for (const c of spec?.containers ?? []) {
const list = (state[c.id] ??= c.required ? [{}] : []);
if (fit(c, list)) fitted.push(c);
list.forEach((values, index) => boxes.push(instanceBox(c, values, index)));
if (c.required || (c.max && list.length >= c.max)) continue;
if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue;
const add = document.createElement("button");
add.type = "button";
add.className = "b05-route__irregular-btn b05-structure__spec-add";
@@ -243,6 +261,8 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
boxes.push(add);
}
root.replaceChildren(...boxes);
// 개수가 밖에서 바뀌었으면 값 변경으로 알림(불러오기 때는 안 함)
if (!quiet) fitted.forEach((c) => hooks.onChange?.(c, c.items[0]));
}
return {
@@ -268,6 +288,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
const now = links?.get() ?? {};
rows.forEach((row) => row.set(now[row.key] ?? null));
},
refresh: paint,
refresh: () => paint(),
recount: () => paint(false),
};
}
+12 -14
View File
@@ -2,8 +2,9 @@
* design_structure_pipe.ts
* 횡단배수 › 배관 명세 — 사용자 구조물 표 01(`docs/raw/구조물정의_표/01_횡단배수_배관.md`) 그대로.
*
* 컨테이너 다섯: 관(필수) · 관 보호공(유입)(필수) · 관 보호공(유출)(필수) · 흙막이 추가(선택 · 여러 개) ·
* 도수로/돌붙임(선택 · 여러 개). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
* 컨테이너 다섯: 관(필수) · 관 보호공(유입)(필수) · 관 보호공(유출)(필수) · 흙막이(선택 · 개수 = 유출
* 「추가 기슭막이(단)」 · 54-1) · 도수로/돌붙임(선택 · 여러 개). 집수정 = 길이 · 전 · 후 없음(54-1).
* 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈.
* 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리) — 흙막이 값은 옛 「기슭막이」
* (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`).
* 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림.
@@ -40,10 +41,13 @@ function guardItems(keys?: Record<string, Record<string, string>>): SpecItem[] {
? {
item: "structure",
map: Object.fromEntries(
Object.entries(keys).map(([structure, k]) => [
structure,
{ key: k[field], choices: choices?.[structure] },
]),
// 키 없는 갈래 = 그 구조에 없는 칸(집수정 길이 · 전 · 후) — 가림
Object.entries(keys)
.filter(([, k]) => k[field])
.map(([structure, k]) => [
structure,
{ key: k[field], choices: choices?.[structure] },
]),
),
}
: undefined;
@@ -121,13 +125,7 @@ export const PIPE_SPEC: StructureSpec = {
],
},
...guardItems({
[BASIN]: {
form: "inlet_structure",
length: "inlet_basin_length_m",
height: "inlet_basin_height_m",
before: "inlet_basin_before_m",
after: "inlet_basin_after_m",
},
[BASIN]: { form: "inlet_structure", height: "inlet_basin_height_m" },
[SOIL]: revetKeys("inlet"),
}),
],
@@ -150,7 +148,7 @@ export const PIPE_SPEC: StructureSpec = {
},
{
id: "soil_add",
title: "흙막이 추가",
title: "흙막이",
required: false,
prefix: "soil_add",
link: "gsoiladd",
@@ -5,6 +5,7 @@
* 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함).
* 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 ·
* 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝).
* 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) — 읽기만.
* ========================================================================== */
import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets";
@@ -18,9 +19,16 @@ export function setPipeSectionsSource(source: () => readonly CrossSection[] | nu
sectionsSource = source;
}
/** 관 길이(m) — 그 관의 주인 측점(관 자리에서 가장 가까운 측점) 횡단 기하 값 · 못 세우면 저장된
* `pipe_length_m` · 그것도 없으면 null. */
export function pipeLengthM(chainageM: number | null): number | null {
/** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch. */
let extraCountSource: ((section: CrossSection) => number) | null = null;
export function setPipeExtraCountSource(source: (section: CrossSection) => number): void {
extraCountSource = source;
}
/** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */
function ownerSection(
chainageM: number | null,
): { owner: CrossSection; sections: readonly CrossSection[] } | null {
const sections = sectionsSource?.() ?? null;
if (chainageM === null || !sections?.length) return null;
const holders = sections.filter(
@@ -30,11 +38,26 @@ export function pipeLengthM(chainageM: number | null): number | null {
const owner = holders.reduce((a, b) =>
Math.abs(b.chainage_m - chainageM) < Math.abs(a.chainage_m - chainageM) ? b : a,
);
return { owner, sections };
}
/** 관 길이(m) — 주인 측점 횡단 기하 값 · 못 세우면 저장된 `pipe_length_m` · 그것도 없으면 null. */
export function pipeLengthM(chainageM: number | null): number | null {
const found = ownerSection(chainageM);
if (!found) return null;
const { owner, sections } = found;
const drawn = computeStoredLayouts(owner, sections)?.culvert?.pipe?.lengthM;
const length = typeof drawn === "number" && drawn > 0 ? drawn : owner.design?.pipe_length_m;
return typeof length === "number" && length > 0 ? length : null;
}
/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 — 횡단 자료가 없으면 null(모름). */
export function pipeExtraCount(chainageM: number | null): number | null {
const owner = ownerSection(chainageM)?.owner;
if (!owner) return null;
return extraCountSource?.(owner) ?? owner.design?.extra_wall_counts?.outlet ?? 0;
}
/** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */
export function guardHeightHint(diameterMm: string): string {
const d = Number(diameterMm || "1000") / 1000;
+4 -1
View File
@@ -73,7 +73,7 @@ import {
createStandardSlot,
} from "../../B06_Section/B06_Section_UI_Page_Panel";
import type { DesignPageHost } from "./design_page";
import { setPipeSectionsSource } from "./design_structure_pipe_calc";
import { setPipeExtraCountSource, setPipeSectionsSource } from "./design_structure_pipe_calc";
import {
bindDesignView,
bindPipeOptions,
@@ -286,7 +286,10 @@ export async function mountCrossView(
ensureSampledWidth,
projectId: () => projectId,
onSaveError: (message) => showToast(`배수관 구간값 저장 실패 — ${message}`, "error"),
onExtraCount: () => structuresPanel.facility.recount(),
});
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
const sectionView = createSectionView(
(chainageM, change) => {
+3
View File
@@ -70,6 +70,7 @@ import {
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations";
import { setPipeSectionsSource } from "./design_structure_pipe_calc";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import "../../B05_Profile/B05_Profile_UI_Style.css";
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
@@ -194,6 +195,8 @@ export async function mountProfileView(
let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS;
let currentSectionDetail: SectionDetailResponse | null = null;
// 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06)
setPipeSectionsSource(() => currentSectionDetail?.cross_sections ?? null);
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
let corridorVisible = true;
let routeReady = false;
@@ -55,7 +55,11 @@ import {
} 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 {
guardHeightHint,
pipeExtraCount,
pipeLengthM,
} from "../A00_Common/design/design_structure_pipe_calc";
import {
hasLinks,
linkFields,
@@ -215,6 +219,8 @@ export interface FacilityOptionsForm {
paintLinks: () => void;
/** 고른 관 측점 — 관 길이(자동) 계산에 씀. */
setChainage: (chainageM: number | null) => void;
/** 횡단 「추가 기슭막이(단)」이 바뀜 — 배관 흙막이 컨테이너 수를 다시 맞춤(54-1 · B06 이 부름). */
recount: () => void;
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). 숨긴 칸은 폼이 받은 옛 값 그대로. */
readOptions: () => Record<string, string | number>;
/** 「구조물 옵션 보이기」에서 끈 레지스트리 옵션 키 — 그 칸을 가린다(값은 옛 값 보존).
@@ -265,8 +271,10 @@ export function createFacilityOptionsForm(
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2);
return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length));
},
// 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1)
count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined),
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
@@ -475,6 +483,9 @@ export function createFacilityOptionsForm(
chainage = chainageM;
if (current === "pipe") pipeForm.refresh();
},
recount() {
if (current === "pipe") pipeForm.recount();
},
onInletStructureChange(handler) {
inletStructureHandler = handler;
},
@@ -494,7 +505,11 @@ export function createFacilityOptionsForm(
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
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 === "pipe") {
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
const { soil_add: _old, ...pipeNames } = names;
pipeForm.write(PIPE_SPEC, options, pipeNames, groups);
}
if (facility === "box_culvert") {
// 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽
boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups);
@@ -26,10 +26,8 @@ export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
"pipe_diameter_mm",
"inlet_type",
"inlet_structure",
"inlet_basin_length_m",
// 집수정 길이 · 전 · 후는 폼에서 빠짐(54-1) — 옛 값 그대로 둠
"inlet_basin_height_m",
"inlet_basin_before_m",
"inlet_basin_after_m",
...revetKeys("inlet"),
"outlet_type",
...revetKeys("outlet"),
+56 -19
View File
@@ -38,6 +38,8 @@ const ROW_GAP_PX = 2;
const LANE_PADDING_PX = 2;
/** 두 알약이 겹친다고 볼 가로 거리(px). 3자 라벨 폭 기준. */
const OVERLAP_PX = 52;
/** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */
const ETC_PILL_CHARS = 3;
/** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다).
* 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을
@@ -115,6 +117,53 @@ function rowAssignments(
return rows;
}
/** 이름마다 남과 갈리는 가장 짧은 머리 — 빈칸 뺌 · 적어도 3글자 · 이름보다 길지 않음. */
function distinctHeads(names: ReadonlyArray<string>): Map<string, string> {
const letters = names.map((name) => [...name.replace(/\s+/g, "")]);
return new Map(
names.map((name, index) => {
const own = letters[index];
let length = ETC_PILL_CHARS;
letters.forEach((other, otherIndex) => {
if (otherIndex === index) return;
let common = 0;
while (common < own.length && own[common] === other[common]) common += 1;
length = Math.max(length, common + 1);
});
return [name, own.slice(0, length).join("")];
}),
);
}
/** 표시 이름 · 알약 글(구조물 id 별). 표시 이름 = 좌측 목록과 같은 중복 번호(시점에 가까운 순
* "이름 1/2…", 2026-08-19) — 툴팁 · 벌룬 제목이 씀. 알약 = 약호 + 그 번호. 기타는 약호가 모두
* 「기타」라 손 이름 머리(distinctHeads) + 번호 · 빈 이름 = 「기타 N」(54-9). */
export function structurePillTexts(
structures: ReadonlyArray<StructureInstance>,
typeMap: ReadonlyMap<string, StructureType>,
): Map<string, { name: string; pill: string }> {
const sorted = [...structures].sort(
(left, right) => structureAnchorM(left) - structureAnchorM(right),
);
const rawNames = sorted.map(
(entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id,
);
const numbered = numberDuplicateNames(rawNames);
const heads = distinctHeads([
...new Set(rawNames.filter((_name, index) => sorted[index].type_id === "etc")),
]);
return new Map(
sorted.map((entry, index) => {
const name = numbered[index];
const suffix = name.slice(rawNames[index].length); // " 1" 또는 ""
const head = etcName(entry)
? heads.get(rawNames[index])
: typeMap.get(entry.type_id)?.style?.abbr;
return [entry.structure_id ?? "", { name, pill: `${head ?? "?"}${suffix}` }];
}),
);
}
/**
* 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다).
*/
@@ -127,28 +176,15 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
const typeMap = new Map(options.types.map((type) => [type.type_id, type]));
const rows = rowAssignments(options.structures, options.x);
// 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 시점에 가까운 순
// "이름 1/2…". 알약 라벨(약호)·툴팁·벌룬 제목이 모두 이 번호를 따른다.
const anchorSorted = [...options.structures].sort(
(left, right) => structureAnchorM(left) - structureAnchorM(right),
);
const numberedNames = numberDuplicateNames(
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]]),
);
// 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이
// 모두 이 번호를 따른다.
const texts = structurePillTexts(options.structures, typeMap);
options.structures.forEach((structure) => {
const id = structure.structure_id;
if (!id) return;
const type = typeMap.get(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) : "";
const { name: displayName, pill } = texts.get(id) ?? { name: structure.type_id, pill: "?" };
const anchorPx = options.x(structureAnchorM(structure));
const selected = options.selectedId === id;
// 이웃과 겹치는 알약만 위·아래 두 줄로 가르고, 혼자면 레인 세로 가운데에 크게 둔다.
@@ -185,8 +221,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
mark.style.top = `${top}px`;
mark.style.borderColor = type?.style?.color ?? "#888";
mark.style.color = type?.style?.color ?? "#888";
// 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호.
mark.textContent = `${type?.style?.abbr ?? "?"}${numberSuffix}`;
// 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호 ·
// 기타는 손 이름 머리(54-9).
mark.textContent = pill;
mark.title = `${displayName} ${positionText(structure, options.stationIntervalM)}`;
mark.dataset.structureId = id;
@@ -21,6 +21,7 @@ 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 { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import {
readGroupItems,
readSummaryItem,
@@ -110,8 +111,13 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance
ctx.endFields.write(target.end_m ?? null, step);
ctx.memoField.value = target.memo ?? "";
ctx.renderOptionFields(shown.options);
ctx.setSummary(readSummaryItem(target.summary_item));
ctx.groupBoxes.set(readGroupItems(target.group_items));
const links = readGroupItems(target.group_items);
const summary = readSummaryItem(target.summary_item);
// 정의 그룹 종류는 종류 단위 잇기 줄이 없음 — 옛 종류 단위 값은 첫 그룹 컨테이너 잇기로 읽음(54-6)
const first = type && !STRUCTURE_SPECS[type.type_id] ? type.groups?.[0]?.id : undefined;
const moved = !!(first && summary && !links[first]);
ctx.setSummary(moved ? null : summary);
ctx.groupBoxes.set(moved ? { ...links, [first!]: summary! } : links);
ctx.groupBoxes.setShapes(target.options);
// 구간 직접 입력 시절에 저장된 항목 호환 — 길이·전길이 옵션이 없으면 저장된
// 시작~종료·기준점에서 역산해 채워 범위 표시·재저장이 이어지게 한다(2026-08-19).
@@ -242,7 +242,8 @@ export function paintStale(
? staleValues(values, known).filter(([key]) => !own.prefixes.some((p) => key.startsWith(p)))
: [];
box.replaceChildren();
box.hidden = !stale.length;
// 상자는 모든 종류에서 숨김(54-1) — 값은 파일에 그대로
box.hidden = true;
if (!stale.length) return;
const legend = document.createElement("legend");
legend.textContent = "지난 정의 값";
@@ -34,7 +34,7 @@ export function buildSummaryRow(): SummaryRow {
const pick = document.createElement("button");
pick.type = "button";
pick.className = "b05-route__irregular-btn";
pick.textContent = "고르기";
pick.textContent = "선택";
pick.dataset.b05Summary = "pick";
const clear = document.createElement("button");
clear.type = "button";
+6 -1
View File
@@ -154,7 +154,12 @@ function relocateSideRows(panelRoot: HTMLElement): void {
if (slotUsable(ownerSlot)) moveRows(panelRoot, SIDE_ROWS, ownerSlot);
} else {
if (!slotUsable(target)) return;
moveRows(panelRoot, SIDE_ROWS, target);
// 배관 유출 칸은 읽기 줄(관 길이 · 좌우 · 상하 안내)을 안 받음(54-1)
const rows =
target === sideSlots.outlet
? SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", "")
: SIDE_ROWS;
moveRows(panelRoot, rows, target);
}
// 고른 구조물이 어느 쪽인지 그룹 테두리로 알린다(2026-08-29 사용자 지시 3).
const group = target.closest<HTMLElement>("fieldset");
@@ -61,6 +61,8 @@ export interface StationControlDeps {
projectId: () => string | null;
/** 구간값 저장 실패 알림 — 화면 토스트는 페이지가 띄운다. */
onSaveError?: (message: string) => void;
/** 다단 단 수가 바뀜 — 좌측 배관 흙막이 컨테이너 수를 맞춤(54-1). */
onExtraCount?: () => void;
}
/** 반폭·기슭막이·유입 구조물 제어 묶음. `load`는 경로가 바뀔 때 세션 값을 다시 읽는다. */
@@ -366,6 +368,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
});
refreshWithLinked(chainageM);
deps.onExtraCount?.();
},
syncCount: (chainageM, built, side = "outlet") => {
const key = extraKey(chainageM, side);
@@ -381,6 +384,14 @@ export function createStationControls(deps: StationControlDeps): StationControls
persistRevetShifts();
persistExtraSpans();
persistExtraCounts();
// 캐시 patch 도 세운 만큼으로 — 세션 키를 지우면 `countFor` 가 옛 patch 수로 돌아감(54-1)
patchCachedDesign(chainageM, {
extra_wall_counts: {
outlet: extraCounts.get(extraKey(chainageM, "outlet")) ?? 0,
basin: extraCounts.get(extraKey(chainageM, "basin")) ?? 0,
},
});
deps.onExtraCount?.();
},
};
@@ -141,6 +141,8 @@ export interface B06StructuresPanel {
facility: {
setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void;
onInletStructureChange: (handler: (value: "auto" | "revet" | "I" | "L" | "U") => void) => void;
/** 횡단 단 수가 바뀜 — 배관 흙막이 컨테이너 수를 맞춤(54-1). */
recount: () => void;
};
}
@@ -494,6 +496,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
facility: {
setInletStructure: (value) => section.facility.setInletStructure(value),
onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler),
recount: () => section.facility.recount(),
},
overrideOptions: (chainageM, patch) => {
const hit = pipeFacilities.find(
+1 -1
View File
@@ -19,7 +19,7 @@ from common_util.common_util_summary_item import GROUP_JOIN
#: 종류 → 선택 컨테이너 (머리, 잇기, 제목)
SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = {
"pipe": (
("soil_add", "gsoiladd", "흙막이 추가"),
("soil_add", "gsoiladd", "흙막이"),
("chute_add", "gchuteadd", "도수로/돌붙임"),
),
"ford_pavement": (("ford_chute", "gfordpavechute", "도수로/돌붙임"),),
@@ -0,0 +1,142 @@
# 54-5 B05 · B06 실시간 조작 속도 · 보안 재확인 (조사 · 보고만)
- 기준 커밋: `6233daab1` (sub_laptop_8 = origin/main_laptop_1)
- 검증 프로젝트: `sub8_검증` (`c81b21f8…`) · 노선 224 · 저장 · 확정 누르지 않음(캐시만 · 브라우저 문맥 버림)
- 측정 도구: 헤드리스 Chrome(실 GPU — ANGLE D3D11 Intel Iris Xe) · 1509×1069 · 페이지 안 계측(Event Timing · Long Task · fetch/XHR 감쌈 · sessionStorage 쓰기 셈 · DOM 변화 셈 · WebGL clear 셈) + CDP 메인 스레드 시간
- ⚠ ORCA 내장 탭이 10초마다 강제로 내려감(다른 창 탭 여럿 · 숨은 탭 10초 뒤 버림 · `tab switch --focus` 로도 같음) → 10초 넘는 측정 불가 → 측정 전용 헤드리스 Chrome 으로 대체(공용 브라우저 아님 · 화면 안 띄움 · ORCA 탭은 닫음)
- 통합 전 판 실행 대조는 안 함(워크트리 금지) → 통합 전 실측값은 커밋 글에 남은 수치로 대조(`9da48d5e7` 9-12 · `1abb456ef` 9-06)
## ① 기준 커밋
속도 · 실시간 · 보안 작업
| sha | 날짜 | 내용 |
|---|---|---|
| 7fdbb343a | 08-23 | 코리도 편집 즉시 반영 · 프리즈 제거(프레임 갭 3603→100ms) |
| 08c0dc322 | 08-29 | 자동저장 제거 · 초기값 스냅샷 |
| a35c8ff35 | 09-03 | 계획선 편집 중 횡단 재계산을 브라우저로 — 서버 왕복 제거 |
| c2f681f9d · 8dca4bbfb | 09-04 | 휠 · 세로 맞춤 실시간(다시 그리기 대신 변환) |
| 3cd717d8f | 09-06 | [저장]·[확정] 정본 서버 재계산 |
| 9da3bce1b | 09-06 | 카드 버튼 즉시 저장 → 캐시 · `designRequestSeq` 제거(최신만 채택은 `B05_Profile_UI_Profile_Preview.ts:35-57` seq 가 맡음) |
| c0bfcba34 | 09-06 | 유토 배분을 서버 Node 로(사용자 확정 · 400ms 잠잠해지면 · 최신만) |
| df2dedcbc · 8e3c1f91f | 09-06 | 진입 중복 호출 제거 |
| 0b30f5b03 · 1abb456ef · 4df5d0a7e | 09-06 | 지형 색인 · 카드 강제 레이아웃 67→1회 · B06 진입 2.3~2.9s → 2.0s |
| 674777626 | 09-06 | 세션 확인 활동시각 쓰기 1분 1회 |
| **9da48d5e7** | 09-12 | **B06 ▲▼ — `refreshCards` 묶음 + 120ms 디바운스 · 한 번 누름 920→17~23ms · 최장 멈춤 180~229ms · 1.5초 누름 누적 멈춤 2,577→473ms** |
좌측 패널 통합(통합 직전 기준 `cd26699d2` 09-29)
| sha | 날짜 | 내용 |
|---|---|---|
| 439f605ba · 7760dcd77 · 2c520537b | 09-29 | 좌측 칸 등록부 · 설계 가게 한 곳 · 저장 한 줄기 |
| 38aae89b2 | 09-29 | 종단도 한 벌(`design_profile.ts`) — B06 위 종단도도 B05 것으로 |
| 8a9456579 | 09-29 | 고름 한 곳 |
| **dd8066275** | 09-29 | **옛 B06 계획선 편집 껍데기 삭제 — 120ms 디바운스 같이 사라짐** · 유토 뱃지 한 계산(전 측점 `structureAreaRows`) |
| 90a2cb9a1 | 09-29 | 한 페이지 뼈대 · 숨은 3D 그리기 멈춤 |
| **473d959d6** | 09-29 | **가운데 보기 전환 — 폼 한 벌 · `pipeReloadTimer` 추가** |
| 7f04bf961 · 767adc4a4 | 09-29~30 | 관 · 옹벽 칸 한 곳 |
| fc54a385d | 10-05 | 구조물 배치 칸을 정의대로 |
| 0a095c14b … 1a9055444 | 10-05~06 | 52장 명세 공용 틀 + 구조물별 폼 |
## ② 수치 (지금 판)
열: 입력→그림 = Event Timing 최대(ms · 괄호 = 그중 JS) · 긴 작업 = 50ms 넘는 멈춤(ms) · 요청 = 조작 하나에 나간 요청(올림/받음) · 캐시 쓰기 = sessionStorage setItem 수 · 좌측 = 좌측 패널 DOM 갈아 끼운 노드 수 · DOM = 전체 · 오버레이 = 잠금 오버레이 뜸
### B05 (종단 보기)
| 조작 | 입력→그림 | 긴 작업 | 요청 | 캐시 쓰기 | 좌측 | DOM | 오버레이 |
|---|---|---|---|---|---|---|---|
| 쉼 3초(3D 켜짐) | — | 0 | 0 | 0 | 0 | 1 | 0 |
| 종단 패널 펼침 | 16~24 | 0 | 0~1(설정 PUT) | 0 | 0 | 0~17 | 0 |
| 구조물 고르기(목록 클릭) | 72~136 (38~54) | 0~57 | 0 | 1 | 95~114 | 106~147 | 0 |
| 좌측 칸 — 돌쌓기 높이 2→1.5 | 16 | 53 · 88 | 0 | 1 (2KB) | 68 | 84 | 0 |
| 좌측 칸 — 돌쌓기 측점 +m | 80~128 | 52~76 · 88 | 0 | 1 (2KB) | 72 | 88 | 0 |
| 좌측 칸 — 메모 6자 타자 | ≤24 | 0 | 0 | 0 | 0 | 0 | 0 |
| 좌측 칸 — 배관 관경 바꿈 | — | 59~64 · 68~93 | **1 · POST detail-basins 1KB/96KB · 211~478ms** | 1~2 | 52~119 | 143~245 | 0 |
| 측점 끌기(구조물 알약 40px) | **160~240 (57~87)** | 59~95 · 62~95 | 0 | 1~2 | 136~158 | 172~182 | 0 |
| 종단 점 ▲ 한 번 | 56~72 (12~22) | 53~78 | 1 · haul-plan 22KB/0.6KB(400ms 뒤) | 2 | 0 | 36 | 0 |
| 종단 점 ▲ 2.5초 길게(약 20회) | 32~56 | 최대 64 | 2 · haul-plan | **42** | 0 | 558 | 0 |
- 쉼 3초 메인 스레드 시간 **1,484~1,600ms (≈50%)** · WebGL clear 700회(초당 ≈230) — 3D 가 가만히 있어도 매 프레임 그림 → 위 모든 B05 조작에 깔리는 바닥 부하
- B05 진입: 5.9초 · 요청 21 · 받음 등고선 geojson **36.7MB** + 지형 미리보기 7.8MB + 등고선 7.0MB + 횡단 상세 2.6MB · 진입 때 `POST route/structures/migrate`(쓰기) · 긴 작업 합 832ms
### B05 ↔ B06 넘김
| 조작 | 긴 작업 | 요청 | 캐시 쓰기 | 좌측 | DOM |
|---|---|---|---|---|---|
| B05 → B06 (첫 번째) | **752** · 214 · 403 · 72 · 57 · 154 (합 1,652) | 6 · pipe-points 96KB 다시 받음(1,036ms) · missing-stations 541ms · haul-plan | 10~11 | 392~525 | 2,402~2,560 |
| B06 → B05 | 198~231 · 104~114 | 1 (structures) | 2 | 207~390 | 863~1,094 |
| B05 → B06 (두 번째) | **1,231~1,242** · 76~98 · 81~125 · 137~148 | 2 (structures · pipe-points — 메인 스레드 막혀 1,308~1,341ms 대기) | 1 | 260~371 | 1,474~1,611 |
- B06 쉼 3초 메인 스레드 5ms · WebGL 0 (숨은 3D 멈춤은 정상)
- 통합 전 B06 페이지 진입 긴 작업 합 1,604~1,728ms(1abb456ef) → 지금은 **같은 페이지 안 보기 바꿈인데도** 1.5~1.7초를 매번 다시 씀
### B06 (횡단 보기)
| 조작 | 입력→그림 | 긴 작업 | 요청 | 캐시 쓰기 | 좌측 | DOM |
|---|---|---|---|---|---|---|
| 종단 점 ▲ 한 번 | 40 | **587** | 1 · haul-plan | 2 | 0 | 900 |
| 종단 점 ▲ 2.5초 길게 | pointerup **344** | **663 · 558 · 545 · 500** · 95 · 162 · 161 (합 2,684) | 4 · haul-plan(325~851ms) | 8 | 0 | 3,564 |
| 횡단 조정 줄 — 카드 암 경계 ▲ | 96 (63) | 68 | 0 | 1 | 67 | 118 |
| 구조물 고르기(돌쌓기) | 128 (74) | 80 | 0 | 2 | **247** | 279 |
| 구조물 고르기(배관) | 120 (75) | 82 | 0 | 2 | **286** | 342 |
| 좌측 칸 — 배관 관경 바꿈 | — | 109 · 54 | **2 · POST detail-basins + haul-plan** | 3 | 52 | 717 |
| 좌측 칸 — 돌쌓기 높이 2→1.5 | 16 | 0 | 0 | **0** | **0** | **0** |
- **B06 ▲ 통합 전 대비(9da48d5e7 실측)**: 한 번 누름 최장 멈춤 180~229 → **587ms** · 길게 누름 누적 멈춤 473ms(1.5초) → **2,684ms(2.5초)** · 9-12 에 고친 것이 9-29 통합에서 되돌아감
- B06 보기에서 돌쌓기 높이 칸을 바꿔도 캐시 쓰기 · 화면 변화 0 (B05 보기에선 즉시 반영) — 속도와 별개 흠 의심 · 확인 필요
- 잠금 오버레이: 모든 조작에서 0 (규칙 맞음)
## ③ 느린 원인 후보 (6)
| # | 자리 | 왜 | 실측 근거 | 생긴 때 |
|---|---|---|---|---|
| S1 | `A00_Common/design/design_profile.ts:125` (`CROSS_PREVIEW_DEBOUNCE_MS = 16`) · `:367-392` · `B06_Section/B06_Section_UI_Section_View.ts:393-397` · `:287-291` · `B06_Section/B06_Section_Cross_Refresh.ts:129-140` | B06 ▲ 마다(초당 10회) 전 측점 재계산 → 바뀐 측점 카드 전부 다시 만들기 → 종단도 두 번 그림(`draw()` + `refreshCards`→`redrawProfile`) → 전 측점 `structureAreaRows` · 옛 B06 120ms 디바운스 사라짐 | B06 ▲ 587ms · 길게 500ms+ ×4 | dd8066275 · 38aae89b2 (통합) |
| S2 | `A00_Common/design/design_view_cross.ts:493-497` (`host.onShow`) | 횡단 보기 보일 때마다 구조물 다시 읽기 · 상세 다시 받기 · `reconcileStaleDesigns` 전 측점 재계산 · 카드 전부 다시 — 바뀐 것 없어도 | B05→B06 1,231ms 한 덩어리 · pipe-points 96KB 매번 | 473d959d6 (통합) |
| S3 | `B05_Profile/B05_Profile_UI_Viewer.ts:357-376` (`animate`) | 3D 가 쉬는 중에도 매 프레임 `renderer.render` — 메인 스레드 ≈50% 상시 점유 → 모든 B05 조작이 그 위에 얹힘 | 쉼 3초 CPU 1.5초 · clear 230/초 | 통합 전부터(90a2cb9a1 은 숨길 때만 멈춤) |
| S4 | `A00_Common/design/design_view_cross.ts:140-143` (`pipeReloadTimer`) → `B05_Profile/B05_Profile_UI_Drainage_Panel.ts:551-553` · `:402-407` · B05 쪽 `:612-613` | 관 옵션 하나 바꿀 때마다 서버 `detail-basins` 왕복(96KB 받음) → 배수유역 목록 다시 그림 → 관 변경 알림 다시 퍼뜨림 — 5장 「조작 중 네트워크 왕복 금지」 어긋남 | 관경 바꿈 POST 163~478ms · B06 DOM 717 | B05 쪽 통합 전부터 · B06 쪽 473d959d6 |
| S5 | `B06_Section/B06_Section_UI_Page_Pipe_Options.ts:144-148` | 관 옵션 바꿀 때 덮는 범위 측점마다 `refreshCard` → 측점마다 공용 종단도 전체 `draw()` — 9-12 에 고친 꼴(측점 수만큼 다시 그림)이 다시 생김 | 코드 읽기 · 관경 바꿈 긴 작업 109ms | 통합 때 커짐(무거운 공용 종단도로 바뀜) |
| S6 | `B05_Profile/B05_Profile_UI_Structures_Panel.ts:393-400` (`liveCommit`) → `B05_Profile_UI_Structures_Panel_Commit.ts:216-219` · `B06_Section/B06_Section_UI_Page_Structures_Panel.ts:167-172` · `A00_Common/design/design_structure_form.ts:158-159` · `:214-234` · B06 쪽 `design_view_cross.ts:207-224` · `:447-462` | 고르기 · 칸 하나 · 알약 끌기마다 좌측 목록 통째 + 명세 폼 다시 만들기 · B06 은 목록을 두 번 다시 그리고 카드 전체 · 공용 종단도까지 | 고름 B05 95~114 · B06 247~286 노드 · 알약 끌기 160~240ms | 통합 · 52장 폼으로 무거워짐 |
덧(조작 아님): B05 진입 등고선 geojson 36.7MB · 진입 때 `structures/migrate` 쓰기 · 유토 배분 `haul-plan` 은 조작마다 22KB 올림이나 사용자 확정 설계(서버 Node · 400ms 잠잠 · 최신만)
## ④ 보안 (흠 11)
인증 · 권한 공통: 모든 B04/B05/B06 라우터가 `protected_with_company`(`main.py:613-637`) → 세션 + 회사 + 프로젝트가 같은 회사인지(`common_util/common_util_auth.py:105` · `:179` · `:187`) · `project_id` UUID 형 · `route_id` 는 프로젝트와 대조 · 조작 중 자동저장 PUT 없음
| # | 무게 | 자리 | 흠 |
|---|---|---|---|
| H1 | **치명** | `B05_Profile/B05_Profile_Structures_Schema.py:175` · `B05_Profile/B05_Profile_Structures_Repository.py:123` · `M02_MasterTemplete/M02_Item_Snapshot.py:67-69` · `:155-165` · `:212-223` | 구조물 저장 때 `structure_id` 아무 글자 그대로 저장 → 저장 뒤 `safe_sync` 가 `base / structure_id` 폴더를 `shutil.rmtree` · 그 자리에 씀 → `"../../.."` · 절대경로로 서버 디스크 폴더 지우기 가능(회사 사용자 누구나) · 안전 이름 `FOLDER_PATTERN`(`:53`) 이 있는데 여기선 안 씀 (코드로 확인함) |
| H2 | 높음 | `B05_Profile/B05_Profile_Schema.py:66-67` · `:272` → `B05_Profile/B05_Profile_Engine_Solver.py:49-155` · `common_util/common_util_surface_sampler.py:134` · `B05_Profile/B05_Profile_Engine_Skeleton.py:284` | `filter_key` · `method` 자유 글자가 파일 이름에 들어감 → 경로 넘기(다른 프로젝트 지형 읽기 · 캐시 파일 쓰기) · confirm 은 값을 저장까지 |
| H3 | 중간 | `B05_Profile/B05_Profile_Structures_Repository.py:200-202` · `common_util/common_util_drainage_pipes.py:345-361` | **52장 명세 칸 서버 검사 없음** — 정의에 없는 키(동적 `{머리}_{n}_{항목}` 포함)는 `continue` 로 통과 · 관 `options` 그대로 저장 · 명세 규칙은 브라우저(`A00_Common/design/design_structure_spec.ts`)에만 · 파이썬 짝 없음 · 자유 글자 길이 제한 없음 |
| H4 | 중간 | `A00_Common/design/design_structure_ford_calc.ts:10` | 세월교 폭→최소 높이 규칙이 브라우저에만 · 서버는 받은 `ford_height_m` 그대로 — 5장 ⛔(브라우저만 계산) |
| H5 | 중간 | `B05_Profile/B05_Profile_Router_Corridor.py:131-160` · 호출 `B05_Profile_UI_Corridor.ts:208` · `:228` | 코리도 PUT 이 브라우저가 만든 기하 · hash 를 검사 없이 저장 · 첫 빌드 때 자동 PUT — 5장 ⛔ · 서버가 만들 수 있음(`B05_Profile_Corridor_Prebuild.py`) |
| H6 | 중간 | `B06_Section/B06_Section_Schema.py:198` · `B06_Section/B06_Section_Router_Confirm.py:124-169` | B06 저장 · 확정이 브라우저 `mass_haul` · 면적 · 관 길이를 먼저 적고 서버 재계산이 덮음 — 재계산 실패하면 로그만 · 브라우저 값 남음 · `standard_cross_section` 검사 없는 dict |
| H7 | 낮음 | `B06_Section/B06_Section_Schema.py:174-177` (대 `:44-47`) | `CrossSectionPatch` 의 `ground_type` 등 자유 글자 · 사전 키 · 목록 길이 제한 없음 |
| H8 | 낮음 | `B06_Section/B06_Section_Router.py:599` | 안 쓰는 `POST cross-design` 이 DB 에 바로 씀(저장 밖 쓰기 길) |
| H9 | 낮음 | `design_view_profile.ts:503` · `:459` · `B05_Profile_UI_Corridor.ts:208` | [저장] 밖 쓰기 — 등고선 간격 PUT · 진입 때 structures/migrate · 코리도 첫 PUT — 5장 어긋남 |
| H10 | 낮음 | `B06_Section_Router.py:431` · replan · cross-preview | `cross_half_width_m` 상한 없음 · `vertices` 길이 제한 없음 · 코리도 크기 검사가 전체 파싱 뒤 |
| H11 | 낮음 | `station_offsets` · `curve_radii` · 관 `chainage_m` · `B05_Profile_Repository.py:256` | NaN/Infinity 통과 · 관 측점이 노선 길이 검사 없음 · `surface_model_id` 조회에 프로젝트 조건 없음(좌표계 코드만 샘) |
- 참고: 프로젝트 권한이 회사 단위뿐(멤버 · 읽기 전용 역할 없음) — 정책 결정 사항
- 서버 정본 재계산: 계획선 저장(`B05_Profile_Router.py:387-402`) · B06 저장(`recompute_server_side`) 맞음 · 구조물은 등록부 대조만(재계산 아님)
## ⑤ 고칠 갈래와 차례 (8 · 작은 것부터)
| 차례 | 갈래 | 크기 | 예상 효과 |
|---|---|---|---|
| 1 | H1 — `structure_id` 를 `FOLDER_PATTERN` 으로 스키마 검사 + `sync_snapshots` 에서 한 번 더 | 몇 줄 | 서버 폴더 지우기 막음(최우선) |
| 2 | H2 — `filter_key` · `method` 를 Literal 화이트리스트 | 몇 줄 | 경로 넘기 막음 |
| 3 | S1 — 횡단 쪽 재계산 120ms 디바운스 되살림 · `onCrossDesignsUpdated` 받는 쪽에서 종단도 두 번째 그리기 뺌 · 숨은 보기 건너뜀 | 작음 | B06 ▲ 587ms → 9-12 수준(최장 ≈200ms · 길게 누적 ≈0.5초) |
| 4 | S5 · S4 — 관 옵션 측점 루프를 `refreshCards` 한 번으로 · `detail-basins` 서버 왕복을 [저장] 때로(조작 중은 로컬) | 작음~중간 | 관 옵션 조작 왕복 0 · 다시 그림 1회 |
| 5 | S2 — 횡단 보기 다시 보일 때 가게 판번호가 같으면 다시 읽기 · 전 측점 재계산 건너뜀 | 중간 | 보기 넘김 1.2~1.7초 → 바뀐 것만 |
| 6 | S3 — 3D 그리기를 「바뀔 때만」(카메라 움직임 · 장면 변경 때 render 요청) | 중간 | 쉬는 중 메인 스레드 ≈50% → ≈0 · B05 조작 전반 체감 |
| 7 | S6 — 고름 · 칸 바꿈 때 좌측 목록 통째 대신 바뀐 줄만 · 폼 다시 만들기 줄임 · B06 목록 두 번 그리기 없앰 | 중간 | 고름 B06 120ms → B05 수준 이하 |
| 8 | H3~H11 — 52장 명세 서버 clean(파이썬 짝 + 거울 시험) · 세월교 높이 서버 검산 · 코리도 서버 생성 · B06 재계산 실패 시 거절 · 안 쓰는 POST 삭제 · [저장] 밖 쓰기 정리 · 상한 · NaN 막기 | 큼 | 5장 「서버 정본」 · 입력 검사 채움 |
## 남은 확인
- B06 보기 돌쌓기 높이 칸 바꿈 반영 0 (캐시 · 화면 모두) — 별도 흠 의심
- B05 배관 관경 바꿈 뒤 고름이 풀린 일 1회(재현 불확실)
- 통합 전 판 실행 대조가 필요하면 브레인 결정(워크트리 필요)
- ORCA 내장 탭 10초 강제 내림 — 다른 창 탭 정리 또는 탭 보이게 두면 풀릴 수 있음
+140 -1
View File
@@ -1,5 +1,5 @@
{
"판": 12,
"판": 13,
"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`가 새 키로 옮긴다.",
"구조물군": {
@@ -3517,6 +3517,145 @@
"옵션": [],
"형상": {}
}
],
"retaining_wall": [
{
"id": "gretaindetail",
"이름": "상세 내용",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"form",
"side",
"weep_hole_diameter_mm",
"weep_hole_area_m2"
],
"형상": {}
}
],
"masonry_wet": [
{
"id": "gmasonwetdetail",
"이름": "상세 내용",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"thickness_top_m",
"thickness_bottom_m",
"back_len_cm",
"stone_kind",
"stone_supply",
"stone_coeff_basis",
"fill_concrete_mpa",
"face_slope_ratio",
"face_role",
"foundation",
"side",
"weep_hole_diameter_mm",
"weep_hole_area_m2",
"blinding_concrete"
],
"형상": {}
}
],
"masonry_dry": [
{
"id": "gmasondrydetail",
"이름": "상세 내용",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"thickness_top_m",
"thickness_bottom_m",
"back_len_cm",
"stone_kind",
"stone_supply",
"stone_coeff_basis",
"fill_concrete_mpa",
"face_slope_ratio",
"face_role",
"foundation",
"side",
"weep_hole_diameter_mm",
"weep_hole_area_m2",
"blinding_concrete"
],
"형상": {}
}
],
"soil_guard": [
{
"id": "gsoilgdetail",
"이름": "상세 내용",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"form",
"side",
"tiers",
"lift_m",
"shift_m",
"thickness_top_m",
"thickness_bottom_m",
"back_len_cm",
"stone_kind",
"stone_supply",
"stone_coeff_basis",
"fill_concrete_mpa",
"face_slope_ratio",
"foundation",
"weep_hole_diameter_mm",
"weep_hole_area_m2"
],
"형상": {}
}
],
"boulder_masonry": [
{
"id": "gboulderdetail",
"이름": "상세 내용",
"옵션": [
"height_m",
"length_m",
"before_m",
"after_m",
"stone_cm",
"bond",
"stone_supply",
"stone_coeff_basis",
"fill_concrete_mpa",
"face_slope_ratio",
"foundation",
"side",
"weep_hole_diameter_mm",
"weep_hole_area_m2",
"blinding_concrete"
],
"형상": {}
}
],
"berm": [
{
"id": "gbermdetail",
"이름": "상세 내용",
"옵션": [
"length_m",
"before_m",
"after_m",
"width_m",
"interval_m",
"slope_deg"
],
"형상": {}
}
]
},
"배치": {},
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
/* 54-9 종단 알약 글 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
* 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다(세션 저장소만 흉내).
*
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const session = new Map();
const fakeStorage = {
getItem: (k) => session.get(k) ?? null,
setItem: (k, v) => session.set(k, String(v)),
removeItem: (k) => session.delete(k),
};
globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage };
globalThis.sessionStorage = fakeStorage;
globalThis.localStorage = fakeStorage;
const STUBS = {
"@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 },
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} });
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const marks = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts"));
const typeMap = new Map([
["etc", { type_id: "etc", name: "기타", style: { abbr: "기타" }, options: [] }],
[
"retaining_wall",
{ type_id: "retaining_wall", name: "옹벽", style: { abbr: "옹" }, options: [] },
],
]);
const etc = (id, chainage, name) => ({
structure_id: id,
type_id: "etc",
placement: "point",
chainage_m: chainage,
options: name === undefined ? {} : { name },
});
const wall = (id, chainage) => ({
structure_id: id,
type_id: "retaining_wall",
placement: "point",
chainage_m: chainage,
options: {},
});
const run = (structures) =>
Object.fromEntries(
[...marks.structurePillTexts(structures, typeMap)].map(([id, text]) => [id, text]),
);
const out = {
// 53-2 시나리오 — 같은 이름 둘 · 다른 이름 · 빈 이름 둘(빈칸 이름 = 빈 이름)
scenario: run([
etc("e1", 40, "배수로정비"),
etc("e2", 60, "울타리"),
etc("e3", 80, ""),
etc("e4", 100, "배수로정비"),
etc("e5", 120, " "),
]),
// 머리가 같은 다른 이름 — 갈릴 때까지 늘림 · 이름이 남의 머리면 그 이름 그대로
prefix: run([
etc("p1", 10, "콘크리트 블록"),
etc("p2", 20, "콘크리트 옹벽"),
etc("p3", 30, "배수로"),
etc("p4", 40, "배수로공"),
etc("p5", 50, "기타 시설"),
etc("p6", 60),
]),
// 다른 종류 규칙은 그대로 — 약호 + 중복 번호 · 기타 하나 빈 이름 = 「기타」
others: run([wall("w1", 10), wall("w2", 30), etc("x1", 20, "가로등"), etc("x2", 40)]),
};
process.stdout.write(JSON.stringify(out));
+1 -1
View File
@@ -39,7 +39,7 @@ SOIL = {
def test_컨테이너마다_한_건_잇기와_그_값():
item = {"type_id": "pipe", "options": {"pipe_diameter_mm": 1000, **SOIL}, "복사본": "pipe@40.0"}
rows = container_rows(item, {"gsoiladd1": RV, "gchuteadd1": MS})
assert [r["그룹"] for r in rows] == ["흙막이 추가 1", "흙막이 추가 2", "도수로/돌붙임 1"]
assert [r["그룹"] for r in rows] == ["흙막이 1", "흙막이 2", "도수로/돌붙임 1"]
assert [r["복사본"] for r in rows] == [
"pipe@40.0__gsoiladd1",
"pipe@40.0__gsoiladd2",
@@ -36,7 +36,7 @@ def test_컨테이너_다섯_필수_셋_선택_둘(out):
["pipe", "관", True],
["inlet", "관 보호공 (유입)", True],
["outlet", "관 보호공 (유출)", True],
["soil_add", "흙막이 추가", False],
["soil_add", "흙막이", False],
["chute_add", "도수로/돌붙임", False],
]
@@ -58,7 +58,8 @@ def test_형태_선택지는_구조를_따름(out):
def test_옛_키로_읽고_같은_키로_씀_옛_칸은_안_씀(out):
back = out["back"]
assert back["pipe_diameter_mm"] == 1000
assert back["inlet_structure"] == "L" and back["inlet_basin_length_m"] == 2
# 집수정 = 길이 · 전 · 후 칸 없음(54-1) — 폼이 안 적음(병합이 옛 값을 지킴)
assert back["inlet_structure"] == "L" and "inlet_basin_length_m" not in back
assert back["outlet_revet_height_m"] == 2.5
# 표 01 에 없는 옛 칸은 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값)
assert "pipe_kind" not in back and "wing_wall_type" not in back
@@ -0,0 +1,58 @@
"""PLAN 54-6 — 사면안정(C군) 종류마다 컨테이너 「상세 내용」(정의 그룹) 하나.
· 기준 측점(측점 범위) 밖 옵션 전부를 담음 · 옵션 키 · 차례 그대로
· 잇기 = 그룹 id(`group_items`) — 종류 단위 잇기 줄은 가림(hidesTypeSummary = 그룹 있음)
"""
import json
from pathlib import Path
from B05_Profile.B05_Profile_Structures_Schema import DEFINITION_PATH, load_structure_types
from M02_MasterTemplete.M02_Table_Fill import _with_group_items
from M02_MasterTemplete.StructOptions import M02_SO_Definition
from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of
KINDS = {
"retaining_wall": "gretaindetail",
"masonry_wet": "gmasonwetdetail",
"masonry_dry": "gmasondrydetail",
"soil_guard": "gsoilgdetail",
"boulder_masonry": "gboulderdetail",
"berm": "gbermdetail",
}
def test_C군_종류마다_컨테이너_하나_옵션_전부():
types = [t for t in load_structure_types() if t.group == "C"]
assert sorted(t.type_id for t in types) == sorted(KINDS)
groups = groups_of()
for item in types:
assert groups[item.type_id] == [
{
"id": KINDS[item.type_id],
"name": "상세 내용",
"options": [o.key for o in item.options],
}
]
def test_정의_검사_통과_판_이력():
doc = json.loads(Path(DEFINITION_PATH).read_text("utf-8"))
assert M02_SO_Definition.check(doc) == []
assert M02_SO_Definition.history_path(doc["판"] - 1).is_file()
def test_잇기는_컨테이너마다_집계_한_줄():
link = {"layer": "system", "owner": None, "key": "SS000001", "name": "옹벽"}
rows = _with_group_items(
[
{
"type_id": "retaining_wall",
"복사본": "s1",
"summary_item": None,
"group_items": {"gretaindetail": link},
}
]
)
assert [r["복사본"] for r in rows] == ["s1", "s1__gretaindetail"]
assert rows[1]["summary_item"] == link and rows[1]["그룹"] == "상세 내용"
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""54-9 종단 알약 글 — 기타가 여럿이어도 알약끼리 구별됨.
• 손 이름 앞 3글자 · 같은 이름이면 목록과 같은 번호 · 빈 이름 = 「기타 N」
• 머리가 같은 다른 이름은 갈릴 때까지 늘림
• 다른 종류 알약(약호 + 중복 번호) · 툴팁 · 목록 이름(손 이름 그대로)은 그대로
화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_9_etc_pill.cjs`).
B05 · B06 은 같은 알약 레인(`buildStructureLane`)을 씀 — 실제 화면은 ORCA 로 따로 본다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_54_9_etc_pill.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_53_2_기타_다섯_알약이_서로_다르다(out):
pills = {key: value["pill"] for key, value in out["scenario"].items()}
assert pills == {
"e1": "배수로 1",
"e2": "울타리",
"e3": "기타 1",
"e4": "배수로 2",
"e5": "기타 2",
}
assert len(set(pills.values())) == 5
def test_툴팁_이름은_손_이름_그대로(out):
names = [value["name"] for value in out["scenario"].values()]
assert names == ["배수로정비 1", "울타리", "기타 1", "배수로정비 2", "기타 2"]
def test_머리가_같은_다른_이름은_갈릴_때까지_늘린다(out):
pills = {key: value["pill"] for key, value in out["prefix"].items()}
assert pills == {
"p1": "콘크리트블",
"p2": "콘크리트옹",
"p3": "배수로",
"p4": "배수로공",
"p5": "기타시",
"p6": "기타",
}
def test_다른_종류_알약은_약호와_번호_그대로(out):
pills = {key: value["pill"] for key, value in out["others"].items()}
assert pills == {"w1": "옹 1", "w2": "옹 2", "x1": "가로등", "x2": "기타"}
def test_알약_레인이_글을_한_곳에서_받는다():
with open(
os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts"), encoding="utf-8"
) as handle:
text = handle.read()
assert "mark.textContent = pill;" in text
assert text.count("structurePillTexts(") == 2 # 정의 하나 · 레인 하나
@@ -94,7 +94,7 @@ def merged(tmp_path_factory) -> dict:
def test_폼이_모르는_칸은_남고_폼이_아는_칸은_폼대로(merged) -> None:
options = merged["out"][0]["options"]
assert options["revet_foundation"] == "기초유" # 집계표 값이 살아남음
assert "inlet_basin_before_m" not in options # 폼 칸(24-4a) — 집수정을 걷으면 함께 지움
assert options["inlet_basin_before_m"] == 1.5 # 집수정 전 — 폼에서 빠짐(54-1) · 옛 값 남음
assert options["pipe_diameter_mm"] == 800 # 폼 값이 이김
# 표 01 명세(PLAN 52-1)가 뺀 옛 칸 = 폼이 모르는 칸 → 남음(지난 정의 값)
assert options["inlet_basin_form"] == "□형(기본형)"
@@ -80,7 +80,7 @@ def test_집계_항목_줄(out):
True,
None,
)
assert (empty["label"], empty["pick"], empty["clear"]) == ("집계 항목", "고르기", "해제")
assert (empty["label"], empty["pick"], empty["clear"]) == ("집계 항목", "선택", "해제")
row = out["rowSet"]
assert row["assigned"] is True and row["clearDisabled"] is False
assert row["name"] == "돌기슭막이 메쌓기 1.2m" and A_KEY in row["title"]
+12 -14
View File
@@ -2,13 +2,13 @@
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트 · 목록에 「배관 1」 「세월교 1」 · 로그인 상태
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5213 = "단계";` 를 붙여
* cache → 저장 안 하고 B05 → B06 → B05 오가기(캐시 값 그대로) · 종류 바꾸기(배관 → BOX → 배관)
* pipe → 배관 길이 · 전 · 후 비움 + 흙막이 추가 셋 + 도수로/돌붙임 하나 → [저장]
* (orca reload) pipe_v → 다시 열기 · 흙막이 수 3 · 비운 칸 그대로
* pipe → 배관 길이 · 전 · 후 비움 + 도수로/돌붙임 하나 → [저장](흙막이 = 유출 단 수 · [추가] 없음 · 54-1)
* (orca reload) pipe_v → 다시 열기 · 흙막이 수 = 저장 전 그대로 · 비운 칸 그대로
* bf → 258.12 를 BOX암거로(PUT) · 가로 비움 · 세월교 1 월류 폭 비움 → [저장]
* (orca reload) bf_v → 다시 열기 · 오류 없음
* restore → 원래 관 지점으로 되돌림(PUT) · 초안 비움
* 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀)
* 기대 수치: errors 0 · cache.same true · kind.leak 0 · soilCount 3 · inletBlank 빈 칸 셋 · restored true
* 기대 수치: errors 0 · cache.same true · kind.leak 0 · soilCount pipe = pipe_v · soilAdd 0 · inletBlank 빈 칸 셋 · restored true
* (배관 도수로/돌붙임 [추가] 는 하나 뒤에도 보임 — 「하나만」은 물넘이 포장 · 기타만 · 52-3 · 52-10)
*/
(async () => {
@@ -66,7 +66,8 @@
await w(500);
return until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
};
const items = () => [...spec().querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem);
const items = () =>
[...spec().querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem);
const point = async (lo, hi) =>
(await fetchApi.fetchDetailPipePoints(projectId)).pipe_points.find(
(p) => p.chainage_m > lo && p.chainage_m < hi,
@@ -89,21 +90,17 @@
}
if (MODE === "pipe") {
sessionStorage.setItem("__5213_original", JSON.stringify((await fetchApi.fetchDetailPipePoints(projectId)).pipe_points));
sessionStorage.setItem(
"__5213_original",
JSON.stringify((await fetchApi.fetchDetailPipePoints(projectId)).pipe_points),
);
await pick(/배관 1$/);
await set("inlet", "structure", "기슭막이");
await set("inlet", "form", "돌망태");
await set("inlet", "length", "");
await set("inlet", "before", "");
await set("inlet", "after", "");
for (let i = 0; i < 3; i++) {
spec().querySelector('[data-spec-add="soil_add"]').click();
await w(200);
}
for (let i = 0; i < 3; i++) {
await set("soil_add", "structure", "기슭막이", i);
await set("soil_add", "form", "콘크리트", i);
}
out.soilAdd = spec().querySelectorAll('[data-spec-add="soil_add"]').length;
spec().querySelector('[data-spec-add="chute_add"]').click();
await w(200);
await set("chute_add", "structure", "돌붙임");
@@ -180,7 +177,8 @@
clearDrafts();
sessionStorage.removeItem("__5213_original");
const now = (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points;
out.restored = JSON.stringify(now.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])) ===
out.restored =
JSON.stringify(now.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])) ===
JSON.stringify(original.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null]));
}
@@ -6,8 +6,8 @@
* orca eval --expression "window.__m521='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 · 관 길이
* orca eval --expression "window.__m521='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움
* 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀)
* 기대 수치(edit): kinds 5 · required 3 늘 보임 · formBasin I형/L형/ㄷ형 · formSoil 6 · split 길이 7 ·
* soilAdd 둘 → 하나 빼면 하나(콘크리트) · chute 하나(돌붙임) · 잇기 유입 지정 · 유출 지정 뒤 풂
* 기대 수치(edit): kinds 4(흙막이 = 유출 단 수 · [추가] 없음 · 54-1) · required 3 늘 보임 · formBasin I형/L형/ㄷ형 ·
* formSoil 6 · split 길이 7 · soilAddButtons 0 · chute 하나(돌붙임) · 잇기 유입 지정 · 유출 지정 뒤 풂
* 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) · 관 길이 수(B06)
* 52-1b: edit 가 흙막이 추가 · 도수로/돌붙임 컨테이너도 이음 → verify 집계표 줄 = 처음 + 3
* (유입 잇기 1 · 컨테이너마다 1)
@@ -120,33 +120,16 @@
out.splitAfter = ctl("inlet", "after").value;
await set("inlet", "after", "4");
out.splitLength = ctl("inlet", "length").value;
// 선택 컨테이너 — 흙막이 추가 둘 → 첫째 뺌 · 도수로/돌붙임 하나
spec().querySelector('[data-spec-add="soil_add"]').click();
await w(200);
spec().querySelector('[data-spec-add="soil_add"]').click();
await w(200);
await set("soil_add", "structure", "기슭막이", 0);
await set("soil_add", "form", "바자", 0);
await set("soil_add", "structure", "기슭막이", 1);
await set("soil_add", "form", "콘크리트", 1);
out.soilAddTwo = spec().querySelectorAll('[data-spec-container="soil_add"]').length;
out.soilFormsSoilAdd = labels("soil_add", "form").length;
box("soil_add", 0).querySelector('[data-spec-remove="soil_add"]').click();
await w(300);
out.soilAddAfterRemove = [...spec().querySelectorAll('[data-spec-container="soil_add"]')].map(
(b, i) => [
b.querySelector("legend").firstChild.textContent,
ctl("soil_add", "form", i).value,
],
);
// 흙막이 = 유출 「추가 기슭막이(단)」 수만큼(54-1) — [추가] · [빼기] 없음 · 도수로/돌붙임 하나
out.soilAddButtons = spec().querySelectorAll('[data-spec-add="soil_add"]').length;
out.soilRemoveButtons = spec().querySelectorAll('[data-spec-remove="soil_add"]').length;
spec().querySelector('[data-spec-add="chute_add"]').click();
await w(200);
await set("chute_add", "structure", "돌붙임");
out.chuteChoices = labels("chute_add", "structure");
// 52-1b — 컨테이너마다 구조물 조합 잇기(집계표 한 줄씩)
await pickLink("soil_add");
await pickLink("chute_add");
out.linkContainers = [linkName("soil_add"), linkName("chute_add")];
out.linkContainers = [linkName("chute_add")];
// 구조물 조합 잇기 — 유입 지정 · 유출 지정 뒤 풂
await pickLink("inlet");
out.linkInlet = linkName("inlet");
@@ -179,7 +162,7 @@
linkOutlet: linkName("outlet"),
};
const b05 = shape();
out.b05Length = ctl("pipe", "length").value;
out.b05Length = box("pipe").querySelector("output")?.textContent; // 보여 주는 칸(54-1)
location.hash = "#/b06-section";
await until(() => location.hash.includes("b06"), 5000);
await w(6000);
@@ -187,7 +170,8 @@
const b06 = shape();
out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06);
if (!out.sameDrawing) out.diff = { b05, b06 };
out.b06Length = ctl("pipe", "length").value;
out.b06Length = box("pipe").querySelector("output")?.textContent;
out.lengthInputs = box("pipe").querySelectorAll('[data-spec-item="pipe.length"] input').length;
out.baseRows = Number(sessionStorage.getItem("__521_baseRows"));
out.rows = await filledRows();
}
@@ -0,0 +1,134 @@
/* 54-1 — B05 · B06 좌측 패널 배관: 「선택」 글 · 지난 정의 값 숨김 · 관 길이 보여 주는 칸(정수) ·
* 유입 집수정 = 길이 · 전 · 후 숨김 · 유출 읽기 줄(관길이 · 좌우 자동 자리) 없음 · [흙막이 추가] 없음 ·
* 흙막이 컨테이너 수 = 유출 「추가 기슭막이(단)」 N
* 시작 화면: B05(#/b05-profile) 또는 B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 「배관 1」(4+5)
* 부르는 법: orca eval --expression "window.__m541='단계'; $(cat 이 파일)"
* look → 지금 화면 배관 폼 수치(아무것도 안 바꿈)
* basin → 유입 구조 집수정 · 흙막이 번갈아 보이는 칸(되돌림 · 저장 안 함)
* tier+ / tier- → B06 유출 벽을 골라 「추가 기슭막이(단)」 + / - 한 번 · 흙막이 컨테이너 수
* 기대: pick 글 전부 「선택」 · 고르기 0 · stale 보임 0 · 관 길이 input 0 · 값 정수 ·
* 집수정 칸 = structure · form · height · 흙막이 칸 = + length · before · after ·
* soil_add 추가 단추 0 · 유출 읽기 줄 0 · soil_add 수 = 단 수
*/
(async () => {
const MODE = window.__m541 || "look";
// 고를 관(목록 이름) — 기본 「배관 1」 · 단이 서는 지형이 아니면 다른 관(window.__m541pipe)
const PIPE = window.__m541pipe || "배관 1";
let station = "";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 20000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const spec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null);
const box = (c, i = 0) => spec()?.querySelectorAll(`[data-spec-container="${c}"]`)[i];
const items = (c) =>
[...(box(c)?.querySelectorAll("[data-spec-item]") ?? [])].map(
(n) => n.dataset.specItem.split(".")[1],
);
const set = async (c, item, v) => {
const el = box(c).querySelector(`[data-spec-item="${c}.${item}"]`).querySelector("select");
el.value = v;
el.dispatchEvent(new Event("change", { bubbles: true }));
await w(300);
};
const pickPipe = async () => {
const chosen = document.querySelector(".b05-route__irregular-list li.is-selected");
if (spec() && chosen?.textContent.trim().endsWith(PIPE)) return;
await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0);
const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
l.textContent.trim().endsWith(PIPE),
);
(li.querySelector("button") ?? li).click();
await until(() => spec(), 5000);
await w(500);
};
const visible = (n) => n.offsetParent !== null && !n.closest("[hidden]");
const tierRow = () =>
box("outlet")?.querySelector(".b06-structure-panel__struct--tier:not(.is-hidden)");
const look = () => {
const lengthField = box("pipe")?.querySelector('[data-spec-item="pipe.length"]');
const picks = [...document.querySelectorAll('[data-b05-summary="pick"]')].filter(visible);
return {
view: location.hash,
pickTexts: [...new Set(picks.map((b) => b.textContent))],
pickCount: picks.length,
gorugi: [...document.querySelectorAll("button")].filter(
(b) => visible(b) && b.textContent.trim() === "고르기",
).length,
staleShown: [...document.querySelectorAll(".b05-structure__stale")].filter(visible).length,
staleLegend: [...document.querySelectorAll("legend")].filter(
(l) => visible(l) && l.textContent.includes("지난 정의 값"),
).length,
lengthInputs: lengthField?.querySelectorAll("input").length,
lengthText: lengthField?.querySelector("output")?.textContent,
inletStructure: box("inlet")?.querySelector('[data-spec-item="inlet.structure"] select')
?.value,
inletItems: items("inlet"),
soilAddButton: spec()?.querySelectorAll('[data-spec-add="soil_add"]').length,
soilRemove: spec()?.querySelectorAll('[data-spec-remove="soil_add"]').length,
chuteAddButton: spec()?.querySelectorAll('[data-spec-add="chute_add"]').length,
soilBoxes: [...(spec()?.querySelectorAll('[data-spec-container="soil_add"]') ?? [])].map(
(b) => ({
legend: b.querySelector("legend").firstChild.textContent,
items: [...b.querySelectorAll("[data-spec-item]")].map(
(n) => n.dataset.specItem.split(".")[1],
),
link: !!b.querySelector('[data-b05-summary="pick"]'),
}),
),
outletReadout: box("outlet")?.querySelectorAll(".b06-structure-panel__struct--readout")
.length,
outletText: box("outlet")?.textContent.includes("자동 자리"),
tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null,
extraGroupShown: [...document.querySelectorAll("legend")].filter(
(l) => visible(l) && l.textContent.startsWith("추가 기슭막이"),
).length,
};
};
await pickPipe();
station = [...document.querySelectorAll(".b05-route__irregular-list li")]
.find((l) => l.textContent.trim().endsWith(PIPE))
.textContent.trim()
.split(PIPE)[0];
const out = { mode: MODE, pipe: PIPE, station };
if (MODE === "look") Object.assign(out, look());
if (MODE === "basin") {
const was = box("inlet").querySelector('[data-spec-item="inlet.structure"] select').value;
await set("inlet", "structure", "집수정");
out.basinItems = items("inlet");
await set("inlet", "structure", "기슭막이");
out.soilItems = items("inlet");
await set("inlet", "structure", was);
out.back = items("inlet");
out.was = was;
}
if (MODE === "tier+" || MODE === "tier-") {
// 유출 벽 고르기(이미 골라져 단 수 줄이 서 있으면 그대로)
if (!tierRow()) {
// 판정면(관 위로 올린 것) — 4+5 카드의 유출 벽
const wall = [...document.querySelectorAll(".b06-chart__culvert-revet-hit")].find(
(e) =>
e.closest(".b06-cross-card")?.textContent.startsWith(station) &&
e.querySelector("title")?.textContent.startsWith("유출"),
);
out.wallFound = !!wall;
wall?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await until(() => tierRow(), 5000);
await w(500);
}
out.before = tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent;
const btns = [...(tierRow()?.querySelectorAll("button") ?? [])];
(MODE === "tier+"
? btns.find((b) => b.textContent === "+")
: btns.find((b) => b.textContent === "-")
)?.click();
await w(1500);
Object.assign(out, look());
}
return JSON.stringify(out);
})();
@@ -0,0 +1,198 @@
/* 54-6 — B05 · B06 좌측 「구조물 배치」 사면안정(C군) 종류마다 컨테이너 「상세 내용」 하나
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조)
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m546 = "단계";` 를 붙여
* seed → 원래 구조물 목록을 sessionStorage 에 두고 흙막이 · 소단을 심고 옹벽에 옛 종류 단위 잇기를 얹음(PUT) · 그 뒤 orca reload
* check → C군 종류마다 컨테이너 1 · 그 안 칸 수 = 그리는 칸 수 · 밖 칸 0 · 잇기 1 · 종류 단위 줄 가림 ·
* 옹벽 컨테이너 잇기 = 옛 값 — B05 에서 한 번(값을 sessionStorage 에 둠) · B06(#/b06-section) 에서 한 번(B05 = B06)
* save → 옹벽 [수정] · [저장] — 옛 값이 컨테이너 잇기(group_items)로 옮겨 저장 · 그 뒤 orca reload
* verify → 다시 연 옹벽 컨테이너 잇기 = 옛 값 · 파일 = group_items 만
* restore → 원래 구조물 목록으로 되돌림(PUT) · 초안 비움
* 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 원래 값으로 다시 씀)
*/
(async () => {
const MODE = window.__m546 || "check";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 20000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const projectId = localStorage.getItem("frd_current_project_id");
const modUrl = (part) =>
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes(part))
.pop();
const api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts"));
const OLD = { layer: "system", owner: null, key: "SS000001", name: "54-6 옛 잇기" };
const KINDS = {
retaining_wall: ["gretaindetail", /^10\+0~10\+10옹벽/],
masonry_wet: ["gmasonwetdetail", /^4\+0~4\+10돌쌓기\(찰\)/],
masonry_dry: ["gmasondrydetail", /^7\+0~7\+10돌쌓기\(메\)/],
soil_guard: ["gsoilgdetail", /흙막이/],
boulder_masonry: ["gboulderdetail", /^16\+0~16\+10큰돌쌓기/],
berm: ["gbermdetail", /소단/],
};
const section = () => document.querySelector(".b05-structure-section");
const selects = () => section().querySelectorAll(".b05-structure__grid select");
const visible = (n) => n.offsetParent !== null;
const listItem = (re) =>
[...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
re.test(l.textContent.trim()),
);
const open = async (re) => {
await until(() => listItem(re));
const li = listItem(re);
(li.querySelector("button") ?? li).click();
await w(800);
if (!selects()[1].value) (li.querySelector("button") ?? li).click(); // 재선택 = 해제 — 한 번 더
await until(() => !!selects()[1].value);
await w(400);
};
const reset = async () => {
section().querySelector(".b05-route__irregular-actions button:last-child").click();
await w(400);
};
const b06 = location.hash.includes("b06");
const types = Object.fromEntries((await api.fetchStructureTypes()).map((t) => [t.type_id, t]));
/** 그리는 칸 = 정의 옵션 중 이 화면 몫(B05 = b05 · B06 = 상세까지) · 켬 */
const expected = (type) =>
types[type].options
.filter((o) => o.visible !== false && o.enabled !== false)
.filter((o) => b06 || api.isB05Option(o))
.map((o) => o.key);
const shape = (type) => {
const boxes = [...section().querySelectorAll("[data-b05-group]")].filter(visible);
const box = boxes[0];
const inside = box ? [...box.querySelectorAll("[data-option-keys]")] : [];
return {
boxes: boxes.map((b) => `${b.dataset.b05Group}:${b.querySelector("legend")?.textContent}`),
fields: inside.map((n) => n.dataset.optionKeys),
want: expected(type),
loose: [...section().querySelectorAll("[data-option-keys]")].filter(
(n) => !n.closest("[data-b05-group]") && visible(n),
).length,
links: box ? box.querySelectorAll(".b05-structure__summary").length : 0,
typeRows: [...section().querySelectorAll(".b05-structure__summary")].filter(
(n) => !n.closest("[data-b05-group]") && visible(n),
).length,
link: box?.querySelector(".b05-structure__summary-name")?.textContent.trim(),
};
};
const ok = (type, s) =>
s.boxes.length === 1 &&
s.boxes[0] === `${KINDS[type][0]}:상세 내용` &&
JSON.stringify(s.fields) === JSON.stringify(s.want) &&
s.loose === 0 &&
s.links === 1 &&
s.typeRows === 0;
const save = async () => {
document.querySelector('[data-dock-action="save"]').click();
await w(500);
return until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
};
const out = { mode: MODE, page: location.hash };
if (MODE === "seed") {
const now = await api.fetchStructures(projectId);
sessionStorage.setItem("__546_original", JSON.stringify(now.structures));
const add = (type_id, at, options) => ({
structure_id: null,
type_id,
placement: "interval",
chainage_m: at,
start_m: at,
end_m: at + 10,
options: { length_m: 10, before_m: 0, after_m: 10, ...options },
memo: "54-6",
});
const next = now.structures.map((s) =>
s.type_id === "retaining_wall" ? { ...s, summary_item: OLD } : s,
);
next.push(add("soil_guard", 500, { height_m: 1.5 }), add("berm", 560, { width_m: 1 }));
await api.saveStructures(projectId, now.revision, next);
for (const k of Object.keys(sessionStorage))
if (k.includes("draft:structures")) sessionStorage.removeItem(k);
const after = (await api.fetchStructures(projectId)).structures;
out.seeded = after.filter((s) => s.type_id in KINDS).map((s) => [s.type_id, !!s.summary_item]);
}
if (MODE === "check") {
await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0);
const r = {};
for (const [type, [, re]] of Object.entries(KINDS)) {
await open(re);
r[type] = shape(type);
r[type].ok = ok(type, r[type]);
}
await reset();
out.allOk = Object.values(r).every((s) => s.ok);
out.oldLink = r.retaining_wall.link;
out.read = r;
if (!b06) sessionStorage.setItem("__546_b05", JSON.stringify(r));
else {
// B06 은 상세 칸까지 그림 — 같은 그리기 판정은 컨테이너 · 잇기 · 밖 칸 · 옛 값
const strip = (x) =>
JSON.stringify(
Object.fromEntries(
Object.entries(x).map(([k, s]) => [
k,
[s.boxes, s.loose, s.links, s.typeRows, s.link, s.ok],
]),
),
);
out.sameDrawing = strip(JSON.parse(sessionStorage.getItem("__546_b05") || "{}")) === strip(r);
}
}
if (MODE === "save") {
await open(KINDS.retaining_wall[1]);
out.before = shape("retaining_wall").link;
section().querySelector(".b05-route__irregular-actions .is-primary").click(); // [수정]
await w(800);
out.saved = await save();
const wall = (await api.fetchStructures(projectId)).structures.find(
(s) => s.type_id === "retaining_wall",
);
out.file = { summary_item: wall.summary_item ?? null, group_items: wall.group_items ?? null };
}
if (MODE === "verify") {
await open(KINDS.retaining_wall[1]);
out.link = shape("retaining_wall").link;
await reset();
const wall = (await api.fetchStructures(projectId)).structures.find(
(s) => s.type_id === "retaining_wall",
);
out.fileOk =
!wall.summary_item &&
JSON.stringify(wall.group_items) === JSON.stringify({ gretaindetail: OLD });
}
if (MODE === "restore") {
const original = JSON.parse(sessionStorage.getItem("__546_original") || "null");
if (!original) return JSON.stringify({ error: "원래 값 없음" });
const now = await api.fetchStructures(projectId);
await api.saveStructures(projectId, now.revision, original);
for (const k of Object.keys(sessionStorage))
if (k.includes("draft:structures") || k.startsWith("__546_")) sessionStorage.removeItem(k);
const after = await api.fetchStructures(projectId);
const key = (list) =>
JSON.stringify(
list.map((s) => [
s.type_id,
s.start_m,
s.chainage_m,
s.end_m,
s.options,
s.summary_item ?? null,
s.group_items ?? null,
]),
);
out.restored = key(after.structures) === key(original);
}
return JSON.stringify(out);
})();