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:
@@ -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]),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
})();
|
||||
Reference in New Issue
Block a user