feat(profile): 종단 알약 정보 라벨 — 맨 위 층 · ▲▼ 위로 비킴 · 이름 · 기준 측점 · 컨테이너마다 첫 값(54-14)

- 레인 쌓임 층 풂 · 라벨 z 6 — ▲▼ 단추층 · 고정 Y축 · 테이블 · 유토곡선 위
- 라벨을 그래프 바닥 ▼ 단추 높이만큼 위로 · 위가 잘리면 덜 잘리는 쪽(알약 아래)
- 줄 = 종류 이름 · 기준 측점 · 좌측 폼 컨테이너마다 첫 옵션 값 · 빈 값 (기본) · auto (자동) · 메모 뺌
- B05 · B06 공용 레인 한 곳 · 시험 test_54_14_balloon_lines · ORCA 시나리오 54-14_알약_라벨

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
This commit is contained in:
2026-10-06 12:02:00 +09:00
co-authored by Claude Opus 5.5
parent fe2aa55bdb
commit 185e74646e
7 changed files with 652 additions and 45 deletions
@@ -52,6 +52,7 @@ import {
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures";
import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc";
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel";
@@ -335,6 +336,11 @@ export function renderProfile(ctx: ProfileRenderContext): void {
ctx.redraw();
},
onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage),
// 라벨 흙막이 상자 수 = 폼과 같은 유출 단 수(54-1 · 54-14)
containerCount: (structure) =>
structure.type_id === "pipe"
? (id) => (id === "soil_add" ? pipeExtraCount(structure.chainage_m ?? null) : undefined)
: undefined,
});
// 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고
// 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정).
@@ -0,0 +1,160 @@
/* =============================================================================
* B05_Profile_UI_Structures_Balloon.ts
* 종단 구조물 알약 정보 라벨 줄(54-14) — 화면 요소 없는 순수 규칙. B05 · B06 알약 레인이 같이 씀.
*
* · 줄 = 종류 이름(기타 = 손 이름) · 기준 측점(구간 = 시작 ~ 끝) · 컨테이너마다 첫 옵션 값 한 줄
* (「관: 관경 1000」) · 컨테이너가 없으면 앞 두 줄만 · 메모 · 그 밖 옵션은 안 씀
* · 컨테이너 = 좌측 패널 폼과 같은 상자 — 명세 종류(`STRUCTURE_SPECS`)는 명세 컨테이너(선택 컨테이너는
* 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`)
* · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀)
* · 빈 값 = 등록부 기본값 「(기본)」 · 값 `auto` = 「(자동)」(54-13 표시와 같음) · 그것도 없으면 「—」
* ========================================================================== */
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box";
import { withLegacyGuards } from "../A00_Common/design/design_structure_ford";
import {
branchOf,
choiceLabel,
choicesOf,
containerTitle,
itemLabel,
readState,
type Instance,
type SpecContainer,
type SpecItem,
type StructureSpec,
} from "../A00_Common/design/design_structure_spec";
const DEFAULT_MARK = "(기본)";
const AUTO_MARK = "(자동)";
const NONE = "—";
/** 선택 컨테이너 개수를 밖에서 정함(배관 흙막이 = 유출 단 수 · 폼 `hooks.count` 와 같은 값). */
export type ContainerCount = (containerId: string) => number | null | undefined;
/** 라벨 줄 — [이름, 기준 측점, ...컨테이너 줄]. */
export function balloonLines(
structure: StructureInstance,
type: StructureType | undefined,
head: { name: string; position: string },
count?: ContainerCount,
): string[] {
const spec = STRUCTURE_SPECS[structure.type_id];
const rest = spec
? specLines(spec, structure, type, count)
: groupLines(structure.options ?? {}, type);
return [head.name, head.position, ...rest];
}
/** 명세 컨테이너 줄 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름. */
function specLines(
spec: StructureSpec,
structure: StructureInstance,
type: StructureType | undefined,
count?: ContainerCount,
): string[] {
let names = type?.spec_names ?? {};
if (structure.type_id === "pipe") {
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
const { soil_add: _old, ...pipeNames } = names;
names = pipeNames;
}
const state = formState(spec, structure.type_id, structure.options ?? {});
// 빈 칸이 그림에서 쓰는 값 — 등록부 기본값을 같은 길로 읽은 필수 컨테이너 값
const fallback = formState(spec, structure.type_id, registryDefaults(type));
const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name]));
return spec.containers.flatMap((c) => {
const title = containerTitle(c, names, groups[c.link]);
return instancesOf(c, state[c.id] ?? [], count).map((values, index) => {
const item = c.items.find(
(entry) => entry.kind !== "auto" && (!entry.by || branchOf(entry, values)),
);
const head = c.required ? title : `${title} ${index + 1}`;
const base = c.required ? (fallback[c.id]?.[0]?.[item?.id ?? ""] ?? "") : "";
return item
? `${head}: ${itemLabel(c, item, names)} ${specValue(c, item, values, names, base)}`
: `${head}: ${NONE}`;
});
});
}
/** 저장 값 → 폼 칸 값 — 폼(`Drainage_Facility.setFacility`)과 같은 손질(BOX 옛 설치 · 본체 규격 ·
* 세월교 옛 날개벽). */
function formState(
spec: StructureSpec,
typeId: string,
options: Record<string, string | number>,
): Record<string, Instance[]> {
const input =
typeId === "box_culvert"
? boxOptionsIn(options)
: typeId === "ford_bridge"
? withLegacyGuards(options)
: options;
const state = readState(spec, input);
// 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음
if (typeId === "box_culvert") state.body = [{ ...state.body?.[0], ...bodyOf(options) }];
return state;
}
/** 보이는 인스턴스 — 필수 = 한 벌 · 선택 = 개수가 정해졌으면 그 수(모자라면 빈 인스턴스) · 아니면 저장된 것. */
function instancesOf(c: SpecContainer, list: Instance[], count?: ContainerCount): Instance[] {
if (c.required) return [list[0] ?? {}];
const n = count?.(c.id);
return n === undefined || n === null
? list
: Array.from({ length: n }, (_, index) => list[index] ?? {});
}
function specValue(
c: SpecContainer,
item: SpecItem,
values: Instance,
names: Record<string, string>,
fallback: string,
): string {
const shown = (raw: string): string => {
const choice = choicesOf(item, values).find((ch) => ch.value === raw);
return choice ? choiceLabel(c, item, choice, names) : raw;
};
const raw = (values[item.id] ?? "").trim();
if (raw === "auto") return AUTO_MARK;
if (raw) return shown(raw);
return fallback.trim() ? `${shown(fallback.trim())} ${DEFAULT_MARK}` : NONE;
}
/** 정의 옵션 그룹 줄 — 그룹마다 첫 옵션(정의에 있고 켜진 칸). */
function groupLines(
options: Record<string, string | number>,
type: StructureType | undefined,
): string[] {
const byKey = new Map((type?.options ?? []).map((option) => [option.key, option]));
return (type?.groups ?? []).map((group) => {
const option = group.options
.map((key) => byKey.get(key))
.find((entry) => entry && entry.visible !== false && entry.enabled !== false);
if (!option) return `${group.name}: ${NONE}`;
const unit = option.unit ?? "";
const raw = options[option.key];
const value =
raw === "auto"
? AUTO_MARK
: raw !== undefined && raw !== ""
? `${raw}${unit}`
: option.default !== null && option.default !== undefined && option.default !== ""
? `${option.default}${unit} ${DEFAULT_MARK}`
: NONE;
return `${group.name}: ${option.label} ${value}`;
});
}
/** 등록부 기본값(키 → 값) — 빈 칸이 그림에서 쓰는 값. */
function registryDefaults(type: StructureType | undefined): Record<string, string | number> {
return Object.fromEntries(
(type?.options ?? [])
.filter((option) => option.default !== null && option.default !== undefined)
.map((option) => [option.key, option.default as string | number]),
);
}
+52 -36
View File
@@ -20,6 +20,7 @@ import {
type StructureType,
} from "./B05_Profile_Api_Structures";
import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
@@ -40,6 +41,8 @@ const LANE_PADDING_PX = 2;
const OVERLAP_PX = 52;
/** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */
const ETC_PILL_CHARS = 3;
/** 라벨을 위로 비키는 높이(px) — 그래프 바닥 ▼ 단추(바닥 2 + 높이 17) + 틈 2(54-14). */
const EDIT_BUTTON_CLEARANCE_PX = 21;
/** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다).
* 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을
@@ -64,18 +67,8 @@ export interface StructureMarksOptions {
selectedId: string | null;
onSelect: (structureId: string | null) => void;
onMove: (structureId: string, toChainageM: number) => void;
}
function optionSummary(structure: StructureInstance, type: StructureType | undefined): string {
if (!type) return "";
return type.options
.map((option) => {
const value = structure.options?.[option.key];
if (value === undefined || value === "") return null;
return `${option.label} ${value}${option.unit ?? ""}`;
})
.filter(Boolean)
.join(" · ");
/** 라벨 선택 컨테이너 개수(폼과 같은 값 · 배관 흙막이 = 유출 단 수) — 없으면 저장된 것. */
containerCount?: (structure: StructureInstance) => ContainerCount | undefined;
}
/** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */
@@ -236,7 +229,17 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
if (selected)
lane.append(
buildBalloon(structure, type, anchorPx, row, options.stationIntervalM, displayName),
buildBalloon(
balloonLines(
structure,
type,
{ name: displayName, position: positionText(structure, options.stationIntervalM) },
options.containerCount?.(structure),
),
type,
anchorPx,
row,
),
);
});
@@ -259,44 +262,57 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
/** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다.
* 알약 **위**로 띄운다 — 아래로 펴면 도면 테이블에 가려 안 보인다(2026-08-18
* 사용자 지시). 2행 구조에서 1행(윗줄)은 좌측 위, 2행(아랫줄)은 우측 위로
* 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. */
* 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다.
* 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값(`balloonLines` · 54-14) · 그래프 바닥 ▼ 단추를 가리지
* 않게 단추 높이만큼 더 위 · 위가 잘리면 알약 아래로. */
function buildBalloon(
structure: StructureInstance,
lines: string[],
type: StructureType | undefined,
anchorPx: number,
row: number | null,
stationIntervalM: number,
/** 중복 번호가 붙은 표시 이름("옹벽 1") — 좌측 목록과 같은 표기(2026-08-19). */
displayName?: string,
): HTMLElement {
const balloon = document.createElement("div");
balloon.className = "b05-structure__balloon";
balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center");
balloon.style.left = `${anchorPx}px`;
balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`;
balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX}px`;
balloon.style.borderColor = type?.style?.color ?? "#888";
const [name, ...rest] = lines;
const title = document.createElement("strong");
title.textContent = displayName ?? type?.name ?? structure.type_id;
const position = document.createElement("span");
position.textContent = positionText(structure, stationIntervalM);
balloon.append(title, position);
const summary = optionSummary(structure, type);
if (summary) {
const detail = document.createElement("span");
detail.textContent = summary;
balloon.append(detail);
}
if (structure.memo) {
const memo = document.createElement("span");
memo.className = "b05-structure__balloon-memo";
memo.textContent = structure.memo;
balloon.append(memo);
}
title.textContent = name;
balloon.append(
title,
...rest.map((line) => {
const span = document.createElement("span");
span.textContent = line;
return span;
}),
);
requestAnimationFrame(() => flipIfClipped(balloon));
return balloon;
}
/** 라벨 위가 스크롤 상자 · 창 밖으로 잘리면 알약 아래로 편다(아래가 덜 잘릴 때만). */
function flipIfClipped(balloon: HTMLElement): void {
const lane = balloon.parentElement;
if (!balloon.isConnected || !lane) return;
let top = 0;
let bottom = window.innerHeight;
for (let at: HTMLElement | null = lane; at; at = at.parentElement)
if (getComputedStyle(at).overflowY !== "visible") {
const clip = at.getBoundingClientRect();
top = Math.max(top, clip.top);
bottom = Math.min(bottom, clip.bottom);
}
const box = balloon.getBoundingClientRect();
const hiddenAbove = top - box.top;
const hiddenBelow = lane.getBoundingClientRect().bottom + ROW_GAP_PX + box.height - bottom;
if (hiddenAbove <= 0 || hiddenBelow >= hiddenAbove) return;
balloon.style.bottom = "auto";
balloon.style.top = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`;
}
/** 마크를 좌우로 끌어 위치를 옮긴다. 구간형은 길이를 유지한 채 통째로 움직인다. */
function attachMarkDrag(
mark: HTMLElement,
@@ -6,11 +6,11 @@
* ========================================================================== */
/* 구조물 알약 레인 — 그래프 바로 아래 한 줄(2026-08-17 사용자 지시 1·3·6).
높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다. */
높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다.
z-index 를 두지 않음 — 쌓임 층을 만들면 라벨이 그 안에 갇혀 ▲▼ 단추층(4) 밑으로 감(54-14). */
.b05-structure__lane {
position: relative;
overflow: visible;
z-index: 3;
}
/* 레인 왼쪽 Y축 띠 — 그래프 sticky Y축과 같은 폭·같은 배경으로 이어 붙여, 가로
@@ -105,7 +105,8 @@
/* 값 벌룬 — 유토곡선 벌룬과 같은 결(테두리 도형 + 여러 줄 텍스트). */
/* 벌룬은 알약 위로 뜬다(2026-08-18) — 아래는 도면 테이블에 가린다. 전개 방향:
1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center). */
1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center).
층 = 종단 패널 맨 위 — ▲▼ 단추층(4) · 고정 Y축 · 테이블 · 유토곡선(5) 위 · 우클릭 메뉴(7) 아래(54-14). */
.b05-structure__balloon {
position: absolute;
z-index: 6;
@@ -122,7 +123,6 @@
line-height: 1.35;
white-space: nowrap;
pointer-events: none;
z-index: 4;
}
.b05-structure__balloon.is-left {
@@ -138,11 +138,6 @@
font-size: 11.5px;
}
.b05-structure__balloon-memo {
opacity: 0.7;
font-style: italic;
}
/* ── 계곡 통과 시설 부속 옵션 서브폼 (구조물 배치 폼 안, 2026-08-17 UI 개편) ──
* 배관·BOX암거·세월교·독립 기슭막이의 부속 옵션을 2열 격자로 담는다. */
.b05-drainage__facility {
@@ -0,0 +1,121 @@
/* 54-14 종단 알약 정보 라벨 줄 단위검증 헬퍼 — 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();
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 (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const { balloonLines } = loadTs(
path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Balloon.ts"),
);
const opt = (key, label, extra = {}) => ({ key, label, default: null, unit: null, ...extra });
const TYPES = {
pipe: {
type_id: "pipe",
name: "배관",
options: [
opt("pipe_diameter_mm", "관경", { default: "1000" }),
opt("inlet_type", "유입", { default: "기슭막이" }),
],
groups: [
{ id: "grv6lny", name: "관", options: [] },
{ id: "gnbf9m9", name: "관 보호공(유입)", options: [] },
{ id: "g9lq9uk", name: "관 보호공(유출)", options: [] },
],
spec_names: { soil_add: "흙막이 추가" },
},
box_culvert: {
type_id: "box_culvert",
name: "BOX암거",
options: [
opt("body_width_m", "폭", { default: 2 }),
opt("body_height_m", "높이", { default: 2 }),
],
groups: [],
},
cross_underdrain: {
type_id: "cross_underdrain",
name: "맹암거",
options: [
opt("culvert_height_m", "높이", { unit: "m" }),
opt("culvert_slope_pct", "기울기", { unit: "%" }),
],
groups: [
{ id: "gcrossud", name: "맹암거", options: ["culvert_height_m", "culvert_slope_pct"] },
],
},
retaining_wall: {
type_id: "retaining_wall",
name: "옹벽",
options: [
opt("height_m", "높이", { default: 2, unit: "m", visible: false }),
opt("length_m", "길이", { default: 10, unit: "m" }),
],
groups: [{ id: "gretaindetail", name: "상세 내용", options: ["height_m", "length_m"] }],
},
mirror: { type_id: "mirror", name: "반사경", options: [], groups: [] },
etc: { type_id: "etc", name: "기타", options: [opt("name", "이름")], groups: [] },
};
const point = (type_id, options = {}) => ({
structure_id: type_id,
type_id,
placement: "point",
chainage_m: 100,
options,
memo: "메모는 안 씀",
});
const head = (name) => ({ name, position: "5+0" });
const run = (structure, count) =>
balloonLines(structure, TYPES[structure.type_id], head("이름"), count);
const out = {
pipeFull: run(
point("pipe", {
pipe_diameter_mm: 1200,
inlet_type: "집수정",
inlet_structure: "L",
outlet_type: "기슭막이",
soil_add_1_structure: "기슭막이",
soil_add_1_form: "돌망태",
chute_add_1_structure: "도수로",
}),
),
pipeBlank: run(point("pipe")),
// 흙막이 개수 = 밖에서 정한 단 수(2) — 저장 1 벌 · 모자란 둘째는 빈 상자
pipeCount: run(point("pipe", { soil_add_1_structure: "기슭막이" }), (id) =>
id === "soil_add" ? 2 : undefined,
),
boxPreset: run(point("box_culvert", { body_width_m: 3, body_height_m: 3, wing_in: "있음" })),
boxBlank: run(point("box_culvert")),
underdrain: run(point("cross_underdrain", { culvert_height_m: 0.6 })),
underdrainBlank: run(point("cross_underdrain")),
wall: run(point("retaining_wall")),
wallAuto: run(point("retaining_wall", { length_m: "auto" })),
mirror: run(point("mirror", { anything: 1 })),
etcEmpty: run(point("etc", { name: "안내판" })),
etcNote: run(point("etc", { name: "안내판", etc_detail_1_note: "시험 비고" })),
};
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""54-14 종단 구조물 알약 정보 라벨 — 중요한 것만.
• 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값 한 줄(컨테이너가 없으면 앞 두 줄만)
• 컨테이너 = 좌측 패널 폼과 같은 상자(명세 컨테이너 · 선택 컨테이너는 인스턴스마다 · 정의 옵션 그룹)
• 빈 값 = 등록부 기본값 「(기본)」 · 값 auto = 「(자동)」 · 그것도 없으면 「—」 · 메모 · 그 밖 옵션은 안 씀
• 라벨은 ▲▼ 단추층 위 · 단추 높이만큼 위로 비킴 · B05 · B06 은 같은 레인 — 실제 화면은 ORCA 로 따로 본다.
화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_14_balloon_lines.cjs`).
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
MARKS = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts")
STYLE = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Style_Structures.css")
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_54_14_balloon_lines.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_배관_컨테이너마다_첫_값_한_줄(out):
assert out["pipeFull"] == [
"이름",
"5+0",
"관: 관경 1200",
"관 보호공(유입): 구조 집수정",
"관 보호공(유출): 구조 흙막이",
"흙막이 1: 구조 흙막이",
"도수로/돌붙임 1: 구조 도수로",
]
def test_빈_값은_등록부_기본값에_기본_표시(out):
assert out["pipeBlank"] == [
"이름",
"5+0",
"관: 관경 1000 (기본)",
"관 보호공(유입): 구조 흙막이 (기본)",
"관 보호공(유출): 구조 —",
]
def test_흙막이_상자_수는_밖에서_정한_단_수(out):
assert out["pipeCount"][-2:] == ["흙막이 1: 구조 흙막이", "흙막이 2: 구조 —"]
assert len(out["pipeCount"]) == 2 + 3 + 2
def test_박스_본체_규격은_폭_높이로_되찾음(out):
assert out["boxPreset"] == [
"이름",
"5+0",
"본체: 본체 규격 3.0 × 3.0",
"유입 구조: 구조 날개벽",
"유출 구조: 구조 —",
]
assert out["boxBlank"][2] == "본체: 본체 규격 2.0 × 2.0 (기본)"
def test_정의_옵션_그룹은_그룹마다_첫_켜진_옵션(out):
assert out["underdrain"] == ["이름", "5+0", "맹암거: 높이 0.6m"]
assert out["underdrainBlank"] == ["이름", "5+0", "맹암거: 높이 —"]
# 끈 칸(높이)은 건너뜀 · 빈 값은 기본값
assert out["wall"] == ["이름", "5+0", "상세 내용: 길이 10m (기본)"]
assert out["wallAuto"] == ["이름", "5+0", "상세 내용: 길이 (자동)"]
def test_컨테이너가_없으면_이름과_기준_측점만(out):
assert out["mirror"] == ["이름", "5+0"]
assert out["etcEmpty"] == ["이름", "5+0"]
assert out["etcNote"] == ["이름", "5+0", "상세 내용 1: 비고 시험 비고"]
def test_라벨은_단추층_위로_비켜_뜬다():
with open(MARKS, encoding="utf-8") as handle:
marks = handle.read()
with open(STYLE, encoding="utf-8") as handle:
style = handle.read()
assert "EDIT_BUTTON_CLEARANCE_PX = 21" in marks
assert "ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX" in marks
assert "balloonLines(" in marks and "structure.memo" not in marks
lane = style.split(".b05-structure__lane {", 1)[1].split("}", 1)[0]
assert "z-index" not in lane # 레인이 쌓임 층을 만들면 라벨이 그 안에 갇힘
balloon = style.split(".b05-structure__balloon {", 1)[1].split("}", 1)[0]
assert balloon.count("z-index") == 1 and "z-index: 6;" in balloon
@@ -0,0 +1,210 @@
/* 54-14 — 종단도 구조물 알약 정보 라벨: 맨 위 층 · ▲▼ 단추를 안 가림 · 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 값
* 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(데스크탑_메인) · 로그인 · 종단 도크에 배관 1(4+5) · 배관 3(22+0)
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5414 = "단계";`
* seed → 처음 초안(세션 원문)을 `__5414_orig` 에 남김 · 구조물 초안에 맹암거 · 반사경 · 기타(안내판 · 비고) 심음
* → 그 뒤 orca reload
* box → 배관 3(22+0) 종류를 BOX암거로(관 초안만 · 저장 안 함)
* check → 배관 · 박스 · 맹암거 · 반사경 · 기타 알약을 눌러 라벨 판정 — B05 에서 한 번(값을 세션에 둠) ·
* B06(#/b06-section) 에서 한 번(B05 = B06)
* restore → 처음 초안 원문으로 되돌림 → 그 뒤 orca reload
* 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__5414_out_{단계}")` 로 읽음
* 판정: 라벨 위 점 15 곳 모두 맨 위 = 라벨 · 라벨 상자와 ▲▼ 단추 상자 겹침 0 · 줄 수 = 1 + 1 + 컨테이너 수
* (컨테이너 수 = 좌측 패널 폼에 보이는 상자 · 줄 머리 = 상자 제목) · B05 = B06
* 쓰는 것: sessionStorage(구조물 · 관 초안) — 정본(서버)은 안 씀
*/
(async () => {
const MODE = window.__m5414 || "check";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 15000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const pid = 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 txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
const visible = (n) => n.offsetParent !== null;
const DRAFTS = /^aislo:draft:(structures|pipes):/;
const b06 = location.hash.includes("b06");
const out = { mode: MODE, page: location.hash, fails: [] };
const fail = (what) => out.fails.push(what);
const marks = () => [...document.querySelectorAll(".b05-structure__mark")];
const TARGETS = {
pipe: (m) => m.dataset.structureId === "pipe-85.05",
box: (m) => m.dataset.structureId === "pipe-440.24",
underdrain: (m) => m.title.startsWith("맹암거"),
mirror: (m) => m.title.startsWith("반사경"),
etc: (m) => m.title.startsWith("안내판"),
};
const openDock = async () => {
const handle = document.querySelector(".ui-workflow-overlay__handle--bottom");
if (handle?.getAttribute("aria-expanded") !== "true") handle?.click();
await until(() => marks().length > 0, 30000);
};
if (MODE === "seed") {
const orig = Object.fromEntries(
Object.keys(sessionStorage)
.filter((k) => DRAFTS.test(k))
.map((k) => [k, sessionStorage.getItem(k)]),
);
sessionStorage.setItem("__5414_orig", JSON.stringify(orig));
const types = Object.fromEntries((await api.fetchStructureTypes()).map((t) => [t.type_id, t]));
const base = api.readPendingStructures(pid) ?? (await api.fetchStructures(pid)).structures;
const add = (id, type_id, at, options) => {
const interval = types[type_id].placement === "interval";
return {
structure_id: id,
type_id,
placement: types[type_id].placement,
chainage_m: at,
start_m: interval ? at - 5 : null,
end_m: interval ? at + 5 : null,
options,
memo: "54-14 메모(라벨에 안 나옴)",
placement_source: "manual",
status: "draft",
revision: 0,
geometry: null,
};
};
api.writePendingStructures(pid, [
...base.filter((s) => !String(s.structure_id).startsWith("t5414")),
add("t5414ud", "cross_underdrain", 600, { culvert_height_m: 0.6 }),
add("t5414mr", "mirror", 660, {}),
add("t5414etc", "etc", 720, { name: "안내판", etc_detail_1_note: "시험 비고" }),
]);
out.seeded = api.readPendingStructures(pid).length - base.length;
}
if (MODE === "box") {
const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const row = () => rows().find((li) => /^22\+0 (배관|BOX암거)/.test(txt(li)));
await until(() => row(), 30000);
if (!row().classList.contains("is-selected")) row().click();
await w(1500);
const sel = document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1];
sel.value = "box_culvert";
sel.dispatchEvent(new Event("change", { bubbles: true }));
await openDock();
await until(() => marks().some((m) => TARGETS.box(m) && m.title.startsWith("BOX암거")));
out.box = marks().find(TARGETS.box)?.title ?? null;
if (!out.box?.startsWith("BOX암거")) fail(`박스 알약 ${out.box}`);
row()?.click(); // 고른 것 풂
await w(800);
}
if (MODE === "check") {
await openDock();
const body = document.querySelector(".b05-structure__lane").closest("[class*='__body']");
const r = {};
for (const [key, find] of Object.entries(TARGETS)) {
const mark = marks().find(find);
if (!mark) {
fail(`${key}: 알약 없음`);
continue;
}
mark.scrollIntoView({ inline: "center", block: "nearest" });
await w(400);
const now = marks().find(find);
if (!now.classList.contains("is-selected")) now.click();
await until(() => document.querySelector(".b05-structure__balloon"));
await w(1200); // 좌측 폼 · 레인 다시 그림
const balloon = document.querySelector(".b05-structure__balloon");
const box = balloon.getBoundingClientRect();
const lines = [...balloon.children].map((c) => c.textContent);
// ① 맨 위 층 — 라벨 위 15 점에서 가장 위 요소가 라벨(재는 동안만 누름 받기 켬)
balloon.style.pointerEvents = "auto";
let top = 0;
for (const fx of [0.1, 0.3, 0.5, 0.7, 0.9])
for (const fy of [0.2, 0.5, 0.8]) {
const hit = document.elementFromPoint(
box.left + box.width * fx,
box.top + box.height * fy,
);
if (hit && balloon.contains(hit)) top += 1;
}
balloon.style.pointerEvents = "";
// ② ▲▼ 단추와 안 겹침(실좌표) · 바로 아래 ▼ 단추와 틈
const buttons = [...document.querySelectorAll(".b05-profile-edit__btn")].map((b) => ({
down: b.classList.contains("is-down"),
rect: b.getBoundingClientRect(),
}));
const overlap = buttons.filter(
({ rect }) =>
rect.left < box.right &&
rect.right > box.left &&
rect.top < box.bottom &&
rect.bottom > box.top,
).length;
const under = buttons.filter(
({ down, rect }) => down && rect.left < box.right && rect.right > box.left,
);
const gap = under.length ? Math.min(...under.map(({ rect }) => rect.top)) - box.bottom : null;
// ③ 줄 수 = 1 + 1 + 좌측 폼 상자 수 · 줄 머리 = 상자 제목
const legends = [
...document.querySelectorAll("fieldset[data-spec-container], [data-b05-group]"),
]
.filter(visible)
.map((f) => {
const legend = f.querySelector("legend").cloneNode(true);
legend.querySelectorAll("button").forEach((b) => b.remove());
return legend.textContent.trim();
});
const heads = lines.slice(2).map((l) => l.slice(0, l.indexOf(": ")));
r[key] = {
lines,
top,
overlap,
gap,
boxes: legends.length,
z: getComputedStyle(balloon).zIndex,
clipTop: body ? box.top - body.getBoundingClientRect().top : null,
};
if (top !== 15) fail(`${key}: 맨 위 아님 ${top}/15`);
if (overlap) fail(`${key}: 단추 겹침 ${overlap}`);
if (lines.length !== 2 + legends.length)
fail(`${key}: 줄 ${lines.length} ≠ 2 + 상자 ${legends.length}`);
if (JSON.stringify(heads) !== JSON.stringify(legends))
fail(`${key}: 줄 머리 ${heads} ≠ 상자 ${legends}`);
if (lines.some((l) => l.includes("메모"))) fail(`${key}: 메모 줄`);
marks().find(find).click(); // 고른 것 풂
await w(800);
}
out.read = r;
const lineMap = JSON.stringify(
Object.fromEntries(Object.entries(r).map(([k, v]) => [k, v.lines])),
);
if (!b06) sessionStorage.setItem("__5414_b05", lineMap);
else {
out.sameAsB05 = sessionStorage.getItem("__5414_b05") === lineMap;
if (!out.sameAsB05) fail("B05 ≠ B06");
}
}
if (MODE === "restore") {
const orig = JSON.parse(sessionStorage.getItem("__5414_orig") || "null");
if (!orig) return JSON.stringify({ error: "원래 값 없음" });
for (const k of Object.keys(sessionStorage)) if (DRAFTS.test(k)) sessionStorage.removeItem(k);
for (const [k, v] of Object.entries(orig)) sessionStorage.setItem(k, v);
const now = Object.fromEntries(
Object.keys(sessionStorage)
.filter((k) => DRAFTS.test(k))
.map((k) => [k, sessionStorage.getItem(k)]),
);
out.restored = JSON.stringify(now) === JSON.stringify(orig);
for (const k of Object.keys(sessionStorage))
if (k.startsWith("__5414_")) sessionStorage.removeItem(k);
}
const result = JSON.stringify(out);
if (MODE !== "restore") sessionStorage.setItem(`__5414_out_${MODE}`, result);
return result;
})();