merge: sub_laptop_1 52-7 — 충돌(import 두 곳 · 판_8 이력) = import 합침 · 판_8 메인 것 · 정의 판 10 · 판_9 이력

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV
This commit is contained in:
2026-10-06 00:03:19 +09:00
co-authored by Claude Opus 5.5
12 changed files with 3887 additions and 46 deletions
@@ -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;
}
@@ -136,9 +136,12 @@ export function createGroupBoxes(): GroupBoxes {
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(
@@ -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";
@@ -345,7 +345,7 @@ 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)),
@@ -19,6 +19,7 @@ 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,
@@ -98,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);
@@ -131,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);
@@ -370,7 +372,7 @@ 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),
@@ -21,14 +21,22 @@ import {
} from "./B05_Profile_UI_Structures_Define";
import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge";
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-6b) · 종단배수(B군 · 조합 잇기 없음 · 표 07). */
* 그리는 종류(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 {
optionInputs: Array<{
@@ -135,12 +143,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> =>
-5
View File
@@ -513,11 +513,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)
+126 -27
View File
@@ -1,5 +1,5 @@
{
"판": 9,
"판": 10,
"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`가 새 키로 옮긴다.",
"구조물군": {
@@ -2646,6 +2646,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",
@@ -2745,26 +2775,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",
@@ -2777,17 +2821,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": "비우면 길이 − 전 으로 셈(길이 = 전 + 후 맞물림)"
}
]
},
@@ -2806,6 +2868,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",
@@ -3335,6 +3411,29 @@
"형상": {}
}
],
"evac_road": [
{
"id": "gevacdetail",
"이름": "상세 내용",
"옵션": [
"detail"
],
"형상": {}
}
],
"guardrail": [
{
"id": "gguarddetail",
"이름": "상세 내용",
"옵션": [
"form",
"length_m",
"before_m",
"after_m"
],
"형상": {}
}
],
"cross_underdrain": [
{
"id": "gcrossud",
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
"""안전부대용지(표 07) — PLAN 52-7.
정의(대피로 · 가드레일 컨테이너 · 점형 일곱) · 옛 대피 종류 읽기(`asRoad` · `pickable`) ·
화면은 ORCA `ui_checks/52-7_안전부대용지.js`.
"""
import json
import os
import subprocess
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(HERE))
POINTS = [
"mirror",
"curb",
"hazard_sign",
"position_sign",
"water_intake",
"completion_sign",
"barrier_gate",
]
def test_정의_검사_통과():
assert definition.check(definition.read()) == []
def test_컨테이너_없는_일곱은_점형_옵션_없음():
types = structure_type_map()
for tid in POINTS:
t = types[tid]
assert (t.group, t.placement, t.options) == ("E", "point", []), tid
assert [types[t].name for t in ("curb", "hazard_sign", "water_intake")] == [
"경계석",
"위험표시",
"취수정",
]
def test_대피로_가드레일_상세_내용_컨테이너():
doc = definition.read()
groups = doc["그룹"]
assert groups["evac_road"] == [
{"id": "gevacdetail", "이름": "상세 내용", "옵션": ["detail"], "형상": {}}
]
assert groups["guardrail"][0]["옵션"] == ["form", "length_m", "before_m", "after_m"]
types = structure_type_map()
road = types["evac_road"]
assert road.placement == "point"
assert road.options[0].choices == ["대피소", "정차/작업장", "차돌림곳"]
guard = types["guardrail"]
assert guard.name == "가드레일" and guard.placement == "interval"
assert [(o.key, o.input) for o in guard.options] == [
("form", "text"),
("length_m", "number"),
("before_m", "number"),
("after_m", "number"),
]
# 옛 대피 종류는 정의에 그대로(옛 기록 도면 · 집계)
assert {"refuge", "work_yard", "turnaround"} <= set(types)
NODE = r"""
const fs = require("fs"), path = require("path");
const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript"));
const src = fs.readFileSync(
path.join(process.argv[1], "B05_Profile", "B05_Profile_UI_Structures_Define.ts"), "utf8");
const js = ts.transpileModule(src, {
compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText;
const m = { exports: {} };
new Function("exports", "module", js)(m.exports, m);
const d = m.exports;
process.stdout.write(JSON.stringify({
old: d.asRoad({ type_id: "turnaround", options: { width_m: 10 } }),
other: d.asRoad({ type_id: "mirror", options: { facing: "좌" } }),
pick: ["refuge", "work_yard", "turnaround", "evac_road", "guardrail"].map(
(type_id) => d.pickable({ type_id })),
}));
"""
def test_옛_대피_종류는_대피로_상세로_읽고_목록에서_뺌():
proc = subprocess.run(
["node", "-e", NODE, ROOT],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
assert out["old"] == {
"type_id": "evac_road",
"options": {"detail": "차돌림곳", "width_m": 10},
}
assert out["other"] == {"type_id": "mirror", "options": {"facing": "좌"}}
assert out["pick"] == [False, False, False, True, True]
@@ -74,7 +74,7 @@ def test_정의_덧칸_얹기(out):
def test_판1_정의는_지금_화면과_같음(out):
assert out["v1Types"] == 38 # 52-6 횡단배수 맹암거 더함
assert out["v1Types"] == 42 # 52-6 맹암거 · 52-7 대피로 · 경계석 · 위험표시 · 취수정
assert out["v1Same"] is True
assert out["v1Columns"] == [2]
assert out["v1Anchor"] is True
@@ -189,7 +189,7 @@ def test_quick_add_types_exclude_pipe_family():
def test_all_enabled_non_managed_types_are_quick_addable():
"""잔존 B05 선택지 = A군 3(노출형·개거·맹암거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1.
"""잔존 B05 선택지 = A군 3(노출형·개거·맹암거) + C군 5 + D군 3 + E군 14 + G군 1(콘크리트 포장) + 기타 1.
콘크리트 포장은 2026-08-28 사용자 지시로 되살렸다 — 포장 구간을 기준측점+길이+전/후로
받는 정본이 됐다(종단경사 자동 포장 적용을 대체).
@@ -201,8 +201,10 @@ def test_all_enabled_non_managed_types_are_quick_addable():
소단 규격은 법령·교본마다 갈려 프로그램이 판정하지 않고 **사용자가 구간에 놓는다** —
그래서 C군 구간형 시설과 같은 자리에 선다. (측구쪽 `ditch_berm` 은 우클릭 추가 대상이
아니라 여기 수에 안 잡힌다.)
E군이 10에서 14로 늘었다 — 표 07(PLAN 52-7) 대피로 · 경계석 · 위험표시 · 취수정.
"""
assert len(_quick_add_types()) == 24
assert len(_quick_add_types()) == 28
def test_removed_types_are_absent():
@@ -0,0 +1,218 @@
/* 52-7 — B05 · B06 좌측 「구조물 배치」 안전부대용지 = 사용자 구조물 표 07
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조)
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m527 = "단계";` 를 붙여
* seed → 원래 구조물을 sessionStorage 에 두고 옛 「대피소」 한 건(refuge · 너비 4 · 유효길이 15)을 더해 PUT
* (orca reload)
* edit → 종류 목록 · 일곱 종류 컨테이너 0 / 잇기 1 · 대피로 상세 3 · 옛 대피소 = 대피로 + 대피소 ·
* 가드레일 길이 10 전 3 → 후 7 · 가드레일 · 대피로 [추가] → [저장]
* (orca reload)
* verify → 다시 연 값 · 옛 대피소 기록 그대로 · B05 = B06 같은 그리기
* restore → 원래 구조물로 되돌림(PUT) · 초안 비움
* 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 원래 목록으로 다시 씀)
*/
(async () => {
const MODE = window.__m527 || "edit";
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 section = () => document.querySelector(".b05-structure-section");
const fieldOf = (label) =>
[...section().querySelectorAll(".b05-route__field")].find(
(f) => f.querySelector("span")?.textContent.trim() === label,
);
const groupSelect = () => fieldOf("구조물군").querySelector("select");
const typeSelect = () => fieldOf("종류").querySelector("select");
const choose = async (el, value) => {
el.value = value;
el.dispatchEvent(new Event("change", { bubbles: true }));
await w(300);
};
const pickType = async (typeId) => {
await choose(groupSelect(), "E");
await choose(typeSelect(), typeId);
};
const anchor = () => fieldOf("기준 측점")?.querySelectorAll("input");
const place = async (station, plus) => {
const [s, r] = anchor();
s.value = String(station);
s.dispatchEvent(new Event("change", { bubbles: true }));
r.value = String(plus);
r.dispatchEvent(new Event("change", { bubbles: true }));
await w(400);
};
// 보이는 칸만 — 안 쓰는 시설 폼에도 같은 키 칸(포장 길이 등)이 숨어 있음
const opt = (key) =>
[...section().querySelectorAll(`[data-option-keys="${key}"]`)]
.find((n) => n.offsetParent !== null)
?.querySelector("input, select");
const setOpt = async (key, value) => {
const el = opt(key);
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
await w(300);
};
const shown = (el) => !!el && el.offsetParent !== null;
// 종류 단위 잇기 = 기준 측점 아래 줄(컨테이너 밖) · 컨테이너 = 정의 그룹 상자
const typeLinks = () =>
[...section().querySelectorAll('[data-b05-summary="row"]')].filter(
(r) => !r.closest("[data-b05-group]") && shown(r),
).length;
const containers = () =>
[...section().querySelectorAll("[data-b05-group]")].filter(shown).map((box) => ({
title: box.querySelector("legend").textContent,
keys: [...box.querySelectorAll("[data-option-keys]")].map((n) => n.dataset.optionKeys),
links: box.querySelectorAll('[data-b05-summary="row"]').length,
}));
const listItem = (name) =>
[...document.querySelectorAll(".b05-route__irregular-list li")].find((l) =>
l.textContent.includes(name),
);
const openItem = async (name) => {
await until(() => listItem(name));
const li = listItem(name);
(li.querySelector("button") ?? li).click();
await w(600);
if (!typeSelect().value) {
(listItem(name).querySelector("button") ?? listItem(name)).click(); // 재선택 = 해제였으면 다시
await w(600);
}
};
const stale = () =>
[...section().querySelectorAll(".b05-structure__stale .b05-route__field")].map((f) => [
f.querySelector("span").textContent,
f.querySelector("input").value,
]);
const form = () => ({
type: typeSelect().selectedOptions[0]?.text,
containers: containers(),
typeLinks: typeLinks(),
values: Object.fromEntries(
[...section().querySelectorAll("[data-option-keys]")]
.filter(shown)
.map((n) => [n.dataset.optionKeys, n.querySelector("input, select").value]),
),
stale: stale(),
});
const out = { mode: MODE };
if (MODE === "seed") {
const now = await api.fetchStructures(projectId);
sessionStorage.setItem("__527_original", JSON.stringify(now.structures));
const legacy = {
structure_id: null,
type_id: "refuge",
placement: "interval",
chainage_m: 150,
start_m: 145,
end_m: 160,
options: { width_m: 4, length_m: 15 },
memo: "52-7 옛 대피소",
placement_source: "manual",
status: "draft",
revision: 0,
geometry: null,
};
await api.saveStructures(projectId, now.revision, [...now.structures, legacy]);
api.writePendingStructures(projectId, null);
const saved = await api.fetchStructures(projectId);
out.seeded = saved.structures.filter((s) => s.type_id === "refuge").length;
out.total = saved.structures.length;
}
if (MODE === "edit") {
await until(() => section() && groupSelect().options.length > 1);
await choose(groupSelect(), "E");
out.picker = [...typeSelect().options].filter((o) => o.value).map((o) => o.text);
// 컨테이너 없는 일곱 — 기준 측점 아래 잇기 1 · 컨테이너 0
out.seven = {};
for (const id of [
"mirror",
"curb",
"hazard_sign",
"position_sign",
"water_intake",
"completion_sign",
"barrier_gate",
]) {
await choose(typeSelect(), id);
out.seven[typeSelect().selectedOptions[0].text] = [containers().length, typeLinks()];
}
// 대피로 — 상세 내용 컨테이너 · 상세 셋
await pickType("evac_road");
await place(7, 0);
out.road = form();
out.roadDetails = [...opt("detail").options].filter((o) => o.value).map((o) => o.text);
await setOpt("detail", "정차/작업장");
section().querySelector(".b05-route__irregular-btn.is-primary").click();
await w(600);
// 가드레일 — 형태 · 길이 10 · 전 3 → 후 7
await pickType("guardrail");
await place(5, 0);
await setOpt("form", "W빔 가드레일");
await setOpt("length_m", "10");
await setOpt("before_m", "3");
out.guard = form();
out.range = section().querySelector(".b05-structure__range-value").textContent;
section().querySelector(".b05-route__irregular-btn.is-primary").click();
await w(600);
// 옛 대피소 기록 = 대피로 + 상세 대피소 · 옛 칸 값은 지난 정의 값
await openItem("대피소");
out.legacy = form();
section().querySelectorAll(".b05-route__irregular-btn")[2].click(); // [리셋] — 안 고치고 닫음
await w(300);
document.querySelector('[data-dock-action="save"]').click();
await w(500);
out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000);
const file = await api.fetchStructures(projectId);
out.file = file.structures
.filter((s) => ["evac_road", "guardrail", "refuge"].includes(s.type_id))
.map((s) => [s.type_id, s.placement, s.chainage_m, s.start_m, s.end_m, s.options]);
}
if (MODE === "verify") {
await openItem("가드레일");
const b05 = form();
out.guard = b05;
await openItem("대피로");
out.road = form();
await openItem("대피소");
out.legacy = form();
location.hash = "#/b06-section";
await until(() => location.hash.includes("b06"), 5000);
await w(6000);
await openItem("가드레일");
const b06 = form();
out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06);
if (!out.sameDrawing) out.diff = { b05, b06 };
location.hash = "#/b05-profile";
await w(3000);
}
if (MODE === "restore") {
const original = JSON.parse(sessionStorage.getItem("__527_original") || "null");
if (!original) return JSON.stringify({ error: "원래 값 없음" });
const now = await api.fetchStructures(projectId);
await api.saveStructures(projectId, now.revision, original);
api.writePendingStructures(projectId, null);
sessionStorage.removeItem("__527_original");
const after = await api.fetchStructures(projectId);
const pick = (list) => JSON.stringify(list.map((s) => [s.type_id, s.chainage_m, s.options]));
out.restored = pick(after.structures) === pick(original);
out.count = after.structures.length;
}
return JSON.stringify(out);
})();