Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_6' into sub_laptop_3

This commit is contained in:
2026-10-11 16:49:13 +09:00
22 changed files with 888 additions and 387 deletions
+2 -1
View File
@@ -90,7 +90,8 @@ export function createSectionFrame(
root.append(heading, body);
return { root, body };
}
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
// ui-panel-form: 좌측 패널 컨테이너 공용 양식(269 · 「구조물 배치」 기준 칸 · 라벨 · 단추).
root.className = `b06-profile__group ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
heading.className = "b06-profile__group-legend ui-collapsible__title";
root.append(heading);
return { root, body: root };
@@ -357,11 +357,11 @@
/* ── 조작 컨트롤 높이 템플릿(2026-08-29 사용자 지시) ──────────────────────
* 「구조물 배치」 섹션 안의 입력·셀렉트·스텝 묶음·이식 행이 **한 높이**를 쓴다.
* 값을 바꾸려면 이 변수 하나만 고치면 된다. 기준은 조정창에서 옮겨 온 행의
* 값은 theme `--panel-control-h` 한 곳(좌측 패널 공용 양식이 함께 씀 · 269). 기준은 조정창에서 옮겨 온 행의
* 컨트롤 높이(26px)다 — 사용자가 그 크기를 기준으로 정했다. */
.b05-structure-section,
.b05-drainage__facility {
--b05-control-h: 26px;
--b05-control-h: var(--panel-control-h);
}
.b05-structure-section input:not([type="checkbox"]),
@@ -409,7 +409,7 @@
/* 제목(라벨)과 항목 사이 간격도 한 변수로 묶는다(2026-08-29 사용자 지시) — 기준은
조정창에서 옮겨 온 행(2px·작은 글자). 측점 행은 가로 배치라 열 간격을 그대로 둔다. */
.b05-structure-section {
--b05-label-gap: 2px;
--b05-label-gap: var(--panel-label-gap);
}
.b05-structure-section .b05-route__field {
+45 -15
View File
@@ -4,10 +4,11 @@
*
* 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 ·
* 칸 크기가 바뀌면 다시 그림) ·
* 가운데 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 →
* [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) ·
* 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) ·
* 가운데 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) ·
* 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`).
* 아래 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 →
* [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) ·
* 머리 한 줄 접기(265-1 · 구조물 측점 폄 · 일반 측점 접힘).
* 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2).
* 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
* 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3).
@@ -38,7 +39,12 @@ import {
import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table";
import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
import { mountNoteCad, stationNoLabel } from "./B06_Section_UI_Cross_Detail_Cad";
import {
loadNoteThings,
mountNoteCad,
stationNoLabel,
thingsAt,
} from "./B06_Section_UI_Cross_Detail_Cad";
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
export interface CrossDetailHost {
@@ -289,26 +295,50 @@ export function openCrossDetail(section: CrossSection): void {
dialogClass: "b06-detail",
wide: true,
mount(body, close) {
// 표 위 빈 CAD(260-2) — 높이 고정 · 한 번만 만듦 · 닫으면 destroy
// 표 위 빈 CAD(260-2) — 높이 고정 · 닫으면 destroy · 머리 한 줄 접기(265-1 · 공용 접기 꼴) ·
// 열 때 = 구조물 측점 폄 · 일반 측점 접힘(그 창에서만) · 처음 펼 때 한 번 만듦(접힌 동안 칸 ·
// 초안 · [저장] 그대로 — 안 연 칸은 B07 이 같은 짝으로 그림)
const noteBox = document.createElement("section");
noteBox.className = "b06-detail__notebox ui-collapsible";
const noteHead = document.createElement("div");
noteHead.className = "b06-detail__notehead ui-collapsible__title";
noteHead.textContent = "표 위 CAD";
const note = document.createElement("div");
note.className = "b06-detail__note";
note.style.height = "180px";
noteBox.append(noteHead, note);
const routeId = host?.routeId();
const stations = host?.detail()?.longitudinal.stations ?? [];
const noteCad =
host && routeId !== null && routeId !== undefined
? mountNoteCad(note, section, () => state.section, {
projectId: host.projectId,
routeId,
station: stationNoLabel(section.chainage_m, inferStationInterval(stations)),
})
: null;
note.querySelector<HTMLElement>(".cad-host")?.style.setProperty("min-height", "0");
let noteCad: { destroy: () => void } | null = null;
const openNote = (): void => {
if (noteCad || !host || routeId === null || routeId === undefined) return;
noteCad = mountNoteCad(note, section, () => state.section, {
projectId: host.projectId,
routeId,
station: stationNoLabel(section.chainage_m, inferStationInterval(stations)),
things,
});
note.querySelector<HTMLElement>(".cad-host")?.style.setProperty("min-height", "0");
};
let touched = false;
noteHead.addEventListener("click", () => {
touched = true;
if (!noteBox.classList.toggle("is-collapsed")) openNote();
});
// 접힘으로 시작 → 그 측점에 놓인 구조물이 있으면 폄(사용자가 먼저 누르면 그대로)
noteBox.classList.add("is-collapsed");
const things = host ? loadNoteThings(host.projectId) : Promise.resolve([]);
void things.then((list) => {
if (touched || current !== state || !thingsAt(list, section.chainage_m).length) return;
noteBox.classList.remove("is-collapsed");
openNote();
});
const cad = document.createElement("div");
cad.className = "b06-detail__cad";
const tableBox = document.createElement("div");
tableBox.className = "b06-detail__table";
body.append(cad, note, tableBox);
// 차례 = 그림 → 표 → CAD 칸(265-1)
body.append(cad, tableBox, noteBox);
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) ·
// 저장된 조각 · 띠 칸 = 그래프에서 고름 + 값 글(259-2) · 고른 칸 묶음 = 바깥 한 줄(259-3)
+120 -74
View File
@@ -1,15 +1,16 @@
/* =============================================================================
* B06_Section_UI_Cross_Detail_Cad.ts
* 횡단 [상세] 모달 가운데 = 표 위 CAD 칸(260 · 요구 162-21).
* 횡단 [상세] 모달 아래 = 표 위 CAD 칸(260 · 요구 162-21 · 표 아래 접기 265-1).
*
* 칸 = `design.structure_note_cad`(CAD 저장 꼴 · 엔티티 · 층) · 세션 초안(`structure-note-cads` ·
* 측점키) → [저장]이 patch 로 내보냄 → B07 횡단도가 수량표 위에 좌상 맞춤으로 놓음
* (`_Engine_Cad_Table.cross_note_cad_entities`).
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없고 횡단배수
* 주인 측점이면 관 · 관보호공 · 관유출부보강 글을 미리 넣음 · 그 밖 = 빈 도면.
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `culvertNoteEntities` ·
* `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 측점은 B07 이 그것으로 그림 ·
* 거울 시험 `test_260_note_cad.py`.
* 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음
* (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 =
* 「이름 조합 미지정」) · 그 밖 = 빈 도면.
* ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` ·
* `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연
* 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`.
* 좌표 = 도면 mm(B07 표와 같은 자 · 글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
* ========================================================================== */
@@ -18,7 +19,18 @@ import { readState, writeState } from "../A00_Common/b_page_state";
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import type { CrossNoteCad, CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch";
import {
fetchStructures,
fetchStructureTypes,
readPendingStructures,
} from "../B05_Profile/B05_Profile_Api_Structures";
import {
readGroupItems,
readSummaryItem,
summaryLabel,
type SummaryItem,
} from "../common_util/common_util_summary_item";
import type { CrossNoteCad, CrossSection } from "./B06_Section_Api_Fetch";
import { pipeLengthWhole } from "./B06_Section_UI_Cross_Culvert_Const";
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
@@ -105,45 +117,11 @@ export function legacyNoteEntities(note: string | undefined): Record<string, unk
);
}
/* ── 횡단배수 미리 넣음(주인 측점 · 관만) ──────────────────────────────────── */
/* ── 미리 넣음(265-2) = 원형 마크(관 주인 측점) + 측점 · 구조물 조합 줄 ─────────────── */
const one = (value: number | null | undefined): string =>
typeof value === "number" && Number.isFinite(value) ? value.toFixed(1) : "";
const metres = (value: number | null | undefined): string => (one(value) ? `${one(value)}m` : "");
/** 관 길이 = 사사오입 정수(265-3 · B08 「관매설」과 같은 값) · 없으면 빈칸. */
const wholeMetres = (value: number | null | undefined): string =>
typeof value === "number" && Number.isFinite(value) ? `${pipeLengthWhole(value)}m` : "";
const trim = (value: number): string => String(Math.round(value * 100) / 100);
/** 돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로. */
const bondOf = (form: string | null | undefined): string =>
/찰/.test(form ?? "") ? "찰" : /메/.test(form ?? "") ? "메" : (form ?? "");
const BASIN_FORMS: Record<string, string> = { I: "I형", L: "L형", U: "ㄷ형" };
/** 관보호공 한 줄 — 구조를 안 고른 쪽 = 없음. */
function sideLine(side: CulvertSideSpec | undefined, title: string): string | null {
if (!side?.structure) return null;
const basin = side.structure === "집수정";
const form = basin ? (BASIN_FORMS[side.basin_form ?? ""] ?? "") : bondOf(side.revet_form);
const height = basin ? side.basin_height_m : side.revet_height_m;
const length = basin ? side.basin_length_m : side.revet_length_m;
const spec = [form, `H=${one(height)}`].filter(Boolean).join(",");
return `관보호공(${title}) ${side.structure}(${spec}) L=${metres(length)}`;
}
/** 도수로/돌붙임 첫 상자(`chute_add_{n}_*`) — 쪽 칸이 없어 유출부로(사용자가 고침). */
function chuteLine(options: Record<string, unknown>, fallback?: string): string | null {
const picked = Object.keys(options)
.map((key) => /^chute_add_(\d+)_structure$/.exec(key))
.filter((hit): hit is RegExpExecArray => !!hit && !!String(options[hit[0]] ?? "").trim())
.sort((a, b) => Number(a[1]) - Number(b[1]))[0];
const head = picked ? `chute_add_${picked[1]}_` : null;
const structure = head ? String(options[`${head}structure`]).trim() : fallback;
if (!structure) return null;
const bond = head ? bondOf(String(options[`${head}bond`] ?? "")) : "";
const area = head ? Number(options[`${head}area`]) : NaN;
const spec = [structure, bond].filter(Boolean).join(",");
return `관유출부보강(${spec}) ${area > 0 ? trim(area) : ""}m2`;
}
/** 측점 이름(B07 `station_no_label` 꼴 · 대문자 NO · 거리는 정수 = 화면 표기). */
export function stationNoLabel(chainageM: number, intervalM: number): string {
@@ -157,6 +135,42 @@ export function stationNoLabel(chainageM: number, intervalM: number): string {
return Math.abs(rest) < 0.05 ? `NO.${number}` : `NO.${number}+${Math.round(rest)}`;
}
/** 측점 글 줄 재료 한 건(관 지점 · 구조물 배치) — 이름 = 구조물정의 종류 이름. */
export interface NoteThing {
name: string;
/** 배관(`facility` pipe) — 원형 마크가 관을 그려 관 본체 조합 줄은 안 씀. */
pipe: boolean;
/** 놓인 누가거리(관 = 관 자리 · 구조물 = 기준 측점 · 없으면 시작). */
at: number;
summary_item?: unknown;
group_items?: unknown;
}
/** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */
const PIPE_BODY_LINK = "grv6lny";
/** 그 측점에 놓인 구조물(관 · 구조물 배치 · 기준 측점이 찾는 거리 안) — 구조물 측점 판정 · 글 줄 재료. */
export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteThing[] =>
things.filter((thing) => Math.abs(thing.at - chainageM) < PIPE_MATCH_M);
/**
* 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 +
* 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 ·
* 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만.
*/
export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] {
return thingsAt(things, chainageM).flatMap((thing) => {
const own = readSummaryItem(thing.summary_item);
const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items));
if (own) slots.unshift(["", own]);
if (!slots.length) return thing.pipe ? [] : [`${thing.name} 조합 미지정`];
const names = slots
.filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK)))
.map(([, item]) => summaryLabel(item));
return [...new Set(names)];
});
}
function shape(type: string, shapeData: Record<string, unknown>): Record<string, unknown> {
return {
id: newId(),
@@ -168,17 +182,9 @@ function shape(type: string, shapeData: Record<string, unknown>): Record<string,
};
}
/**
* 횡단배수 미리 넣음 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관
* (독립 기슭막이) · 이웃 측점 = 빈 목록. `options` = 그 관의 `pipe_points` 옵션(돌붙임 형태 · 면적).
* 왼쪽 원 안 세 줄(배수관 / Φ관경 / L=관 길이 · 없으면 빈칸) · 오른쪽 = 측점 · 관보호공(유입 · 유출) ·
* 관유출부보강.
*/
export function culvertNoteEntities(
section: CrossSection,
station: string,
options: Record<string, unknown> = {},
): Record<string, unknown>[] {
/** 원형 마크 — 관이 이 측점에 **놓인**(주인) 배수관만 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) ·
* 이웃 측점 = 없음 · 원 안 세 줄(배수관 / Φ관경 / L=관 길이 정수 · 없으면 빈칸) + 지시선. */
function pipeMark(section: CrossSection): Record<string, unknown>[] {
const culvert = section.culvert;
if (!culvert || culvert.type !== "pipe" || culvert.body || culvert.hidden_pipe) return [];
if (Math.abs((culvert.chainage_m ?? section.chainage_m) - section.chainage_m) >= PIPE_MATCH_M)
@@ -189,12 +195,6 @@ export function culvertNoteEntities(
`Φ${Math.round(culvert.diameter_m * 1000)}m/m`,
`L=${wholeMetres(section.design?.pipe_length_m)}`,
];
const right = [
station,
sideLine(culvert.inlet, "유입"),
sideLine(culvert.outlet, "유출"),
chuteLine(options, culvert.chute?.structure),
].filter((line): line is string => !!line);
return [
shape("Circle", { center: { x: cx, y: cy }, radius }),
shape("Line", {
@@ -204,29 +204,73 @@ export function culvertNoteEntities(
...inside.map((line, index) =>
textEntity(line, cx, cy + LINE * (1 - index), CULVERT_COLOR, "center"),
),
...right.map((line, index) => textEntity(line, 15, -LINE * (index + 0.5), CULVERT_COLOR)),
];
}
/** 관 옵션 — 세션 초안(관 목록 주인 = 설계 가게) → 없으면 정본 `pipe_points.json`. */
async function pipeOptionsAt(
projectId: string,
chainageM: number,
): Promise<Record<string, unknown>> {
const pipes = readPendingPipes(projectId) ?? (await fetchDetailPipePoints(projectId)).pipe_points;
const pipe = pipes.find((point) => Math.abs(point.chainage_m - chainageM) < PIPE_MATCH_M);
return pipe?.options ?? {};
/**
* 미리 넣음 — 원형 마크(있으면) + 오른쪽 측점 · 구조물 조합 줄(`lines` = `structureNoteLines`) ·
* 마크 없으면 글이 왼쪽 끝부터 · 마크도 줄도 없으면 빈 목록.
*/
export function noteEntities(
section: CrossSection,
station: string,
lines: readonly string[],
): Record<string, unknown>[] {
const mark = pipeMark(section);
if (!mark.length && !lines.length) return [];
const x = mark.length ? 15 : 0;
return [
...mark,
...[station, ...lines].map((line, index) =>
textEntity(line, x, -LINE * (index + 0.5), CULVERT_COLOR),
),
];
}
/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 횡단배수 미리 넣음 → 빈 도면. 옮기거나 넣었으면 초안에 씀. */
/** 글 줄 재료 — 관(세션 초안 → 정본 `pipe_points.json`) · 구조물(세션 초안 → 정본 `structures.json`) ·
* 이름 = 구조물정의 종류 이름(꺼진 종류 포함) · 못 받으면 빈 목록. */
export async function loadNoteThings(projectId: string): Promise<NoteThing[]> {
try {
const [pipes, structures, types] = await Promise.all([
readPendingPipes(projectId) ??
fetchDetailPipePoints(projectId).then((res) => res.pipe_points),
readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures),
fetchStructureTypes(true),
]);
const names = new Map(types.map((type) => [type.type_id, type.name]));
const nameOf = (kind: string): string => names.get(kind) ?? kind;
const piped = pipes.map((point): NoteThing => {
const kind = point.facility ?? "pipe";
const { summary_item, group_items } = point;
return {
name: nameOf(kind),
pipe: kind === "pipe",
at: point.chainage_m,
summary_item,
group_items,
};
});
const placed = structures.flatMap((item): NoteThing[] => {
const at = item.chainage_m ?? item.start_m;
if (typeof at !== "number" || !Number.isFinite(at)) return [];
const { summary_item, group_items } = item;
return [{ name: nameOf(item.type_id), pipe: false, at, summary_item, group_items }];
});
return [...piped, ...placed];
} catch {
return [];
}
}
/** 처음 열 때 칸 — 초안 · 저장분 → 옛 글 → 미리 넣음(원형 마크 · 구조물 조합 줄) → 빈 도면.
* 옮기거나 넣었으면 초안에 씀. */
async function firstNoteCad(section: CrossSection, at: NoteCadPlace): Promise<CrossNoteCad> {
const stored = noteCadOf(section, at);
if (stored) return stored;
let entities = legacyNoteEntities(section.design?.structure_note);
if (!entities.length && section.culvert) {
const owner = section.culvert.chainage_m ?? section.chainage_m;
const options = await pipeOptionsAt(at.projectId, owner).catch(() => ({}));
entities = culvertNoteEntities(section, at.station, options);
if (!entities.length) {
const lines = structureNoteLines(await at.things, section.chainage_m);
entities = noteEntities(section, at.station, lines);
}
const cad: CrossNoteCad = { entities, layers: [BLANK_LAYER] };
if (entities.length) writeNoteCad(section, cad, at);
@@ -240,10 +284,12 @@ export interface NoteCadPlace {
routeId: number;
/** 미리 넣음 첫 줄(`stationNoLabel`). */
station: string;
/** 글 줄 재료(`loadNoteThings` · 모달이 한 번 받음). */
things: Promise<NoteThing[]>;
}
/**
* [상세] 모달 가운데 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 ·
* [상세] 모달 아래 칸(`first` = 연 측점 · `section` = 카드를 다시 그려 바뀐 지금 측점) — 캔버스만(`bare`) · 그리기 됨 · 고칠 때마다 편집본을 초안에 씀 ·
* 모달을 닫으면 `destroy`. 칸 크기가 바뀌어도 다시 만들지 않음(iframe 이 스스로 맞춤).
*/
export function mountNoteCad(
+1 -7
View File
@@ -51,8 +51,6 @@
문제 제거(2026-08-06 사용자 보고). */
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
color: var(--color-text-body);
font-size: var(--text-caption);
}
.b06-profile__group-legend {
@@ -64,7 +62,7 @@
/* 라벨·입력·버튼을 **한 줄**로 세운다(횡단 보기 설정, 2026-08-23 사용자 지시).
ui-field는 기본이 세로(라벨 위·입력 아래·그 아래 오류 슬롯)라 그대로 두면 버튼이
오류 슬롯 높이만큼 밀려 수평이 안 맞는다 — 이 줄에서만 가로로 눕히고 오류 슬롯을 접는다. */
오류 슬롯 높이만큼 밀려 수평이 안 맞는다 — 이 줄에서만 가로로 눕힘(오류 슬롯 접기 = 공용 양식 `.ui-panel-form`). */
.b06-profile__field-row {
display: flex;
align-items: center;
@@ -91,10 +89,6 @@
min-width: 0;
}
.b06-profile__field-row .ui-field__error {
display: none;
}
/* 창이 좁아 좌측 패널이 237px 까지 물러난 자리 — 라벨이 안 줄어 2px 이 새어 나왔다.
그 폭에서만 라벨을 윗줄로 접는다. 넓은 폭 배치는 손대지 않는다(2026-09-08 실측).
⚠ 폭 제한 없이 `flex-wrap` 을 주면 넓은 폭에서도 라벨이 윗줄로 올라간다 — 재 보고 되돌렸다. */
@@ -1,6 +1,6 @@
/* =============================================================================
* B06_Section_UI_Style_Cross_Detail.css
* 횡단 카드 [상세] 단추 · 모달(138-4 · 150-2) — 공용 큰 창 · 그림(남은 높이) → 글 칸 → B07 수량표.
* 횡단 카드 [상세] 단추 · 모달(138-4 · 150-2) — 공용 큰 창 · 그림(남은 높이) → B07 수량표 → CAD 칸(265-1).
* ========================================================================== */
/* 측구 줄 맨 오른쪽 — ⋯ 가 보이면 그 옆, 숨으면 혼자 오른쪽 끝 */
@@ -18,6 +18,20 @@
overflow: hidden;
}
/* 표 위 CAD 칸(265-1) — 표 아래 · 머리 한 줄 접기(공용 `ui-collapsible` · 접히면 그림이 남은 높이) */
.b06-detail__notebox {
flex: none;
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
}
.b06-detail__notehead {
font-size: var(--text-body-sm);
font-weight: 500;
color: var(--color-text);
}
.b06-detail__note {
flex: none;
width: 100%;
@@ -1,10 +1,13 @@
"""B07 횡단도 표 위 CAD 칸 — 횡단배수 미리 넣음(260-4 · 요구 162-21).
"""B07 횡단도 표 위 CAD 칸 — 미리 넣음(260-4 · 265-2 · 요구 162-21 · 164-5).
⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `culvertNoteEntities` ·
`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py`.
⚠ 짝(①) = `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts` `noteEntities` · `structureNoteLines` ·
`stationNoLabel` — 같은 입력 → 같은 엔티티(id 만 다름) · 거울 시험 `test_260_note_cad.py` ·
`test_265_note_cad.py`.
B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 그림을 놓음 · 사용자가 지운 칸
(빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저
(모달이 처음 열 때 옮기는 차례와 같음).
그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된
구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」).
좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수.
"""
@@ -14,16 +17,26 @@ import re
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file
from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points
# TS `PIPE_MATCH_M`(`design_selection`) — 관 자리와 측점이 같은 자리로 보는 폭
# TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭
PIPE_MATCH_M = 0.51
_LAYER_ID = "기본"
_FONT = 2.0
_LINE = 3.0
_COLOR = "#ff7f7f"
_BASIN_FORMS = {"I": "I형", "L": "L형", "U": "ㄷ형"}
# 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리
_PIPE_BODY_LINK = "grv6lny"
_LAYERS = ("system", "company", "personal")
_GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$")
#: (관 누가거리, 세트, 옵션) — 원형 마크 재료
Pipe = tuple[float, dict[str, Any], dict[str, Any]]
#: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items}` — TS `NoteThing`
Thing = dict[str, Any]
def _js_round(value: float) -> int:
@@ -31,74 +44,13 @@ def _js_round(value: float) -> int:
return math.floor(value + 0.5)
def _one(value: Any) -> str:
ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
return f"{value:.1f}" if ok else ""
def _metres(value: Any) -> str:
return f"{_one(value)}m" if _one(value) else ""
def _number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def _whole_metres(value: Any) -> str:
"""관 길이 = 사사오입 정수(265-3 · TS `pipeLengthWhole` · B08 「관매설」과 같은 값)."""
return f"{_js_round(value)}m" if _one(value) else ""
def _trim(value: float) -> str:
rounded = _js_round(value * 100) / 100
return str(int(rounded)) if rounded == int(rounded) else str(rounded)
def _bond(form: Any) -> str:
"""돌쌓기(찰) · 찰붙임 → 찰 · (메) · 메붙임 → 메 · 그 밖 = 그대로."""
text = str(form) if form is not None else ""
return "찰" if "찰" in text else "메" if "메" in text else text
def _side_line(side: Any, title: str) -> str | None:
if not isinstance(side, dict) or not side.get("structure"):
return None
basin = side["structure"] == "집수정"
form = (
_BASIN_FORMS.get(side.get("basin_form") or "", "")
if basin
else _bond(side.get("revet_form"))
)
height = side.get("basin_height_m" if basin else "revet_height_m")
length = side.get("basin_length_m" if basin else "revet_length_m")
spec = ",".join(part for part in (form, f"H={_one(height)}") if part)
return f"관보호공({title}) {side['structure']}({spec}) L={_metres(length)}"
def _area(value: Any) -> float:
"""JS `Number()` 꼴 — 빈 글 = 0 · 못 읽음 = nan."""
if value is None:
return math.nan
if isinstance(value, str) and not value.strip():
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return math.nan
def _chute_line(options: dict[str, Any], fallback: str | None) -> str | None:
picked = sorted(
int(hit.group(1))
for key, value in options.items()
if (hit := re.match(r"^chute_add_(\d+)_structure$", key))
and str(value if value is not None else "").strip()
)
head = f"chute_add_{picked[0]}_" if picked else None
structure = str(options[f"{head}structure"]).strip() if head else fallback
if not structure:
return None
bond_raw = options.get(f"{head}bond") if head else None
bond = _bond(bond_raw if bond_raw is not None else "") if head else ""
area = _area(options.get(f"{head}area")) if head else math.nan
spec = ",".join(part for part in (structure, bond) if part)
return f"관유출부보강({spec}) {_trim(area) if area > 0 else ''}m2"
return f"{_js_round(value)}m" if _number(value) else ""
def station_no_label(chainage_m: float, interval_m: float) -> str:
@@ -112,6 +64,59 @@ def station_no_label(chainage_m: float, interval_m: float) -> str:
return f"NO.{number}" if abs(rest) < 0.05 else f"NO.{number}+{_js_round(rest)}"
def _summary_item(value: Any) -> dict[str, Any] | None:
"""TS `readSummaryItem` — 모양이 맞으면 그대로 · 틀리면 None."""
if not isinstance(value, dict):
return None
owner, key = value.get("owner"), value.get("key")
ok = (
value.get("layer") in _LAYERS
and (owner is None or isinstance(owner, str))
and isinstance(key, str)
and key != ""
and isinstance(value.get("name"), str)
)
return value if ok else None
def _group_items(value: Any) -> list[tuple[str, dict[str, Any]]]:
"""TS `readGroupItems` — 그룹 id · 항목 모양이 맞는 것만(차례 그대로)."""
if not isinstance(value, dict):
return []
return [
(gid, item)
for gid, raw in value.items()
if _GROUP_ID.match(str(gid)) and (item := _summary_item(raw))
]
def things_at(things: list[Thing], chainage_m: float) -> list[Thing]:
"""그 측점에 놓인 구조물 — TS `thingsAt`."""
return [thing for thing in things if abs(thing["at"] - chainage_m) < PIPE_MATCH_M]
def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]:
"""구조물 조합 줄 — TS `structureNoteLines` 짝.
본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌.
"""
lines: list[str] = []
for thing in things_at(things, chainage_m):
own = _summary_item(thing.get("summary_item"))
slots = ([("", own)] if own else []) + _group_items(thing.get("group_items"))
if not slots:
if not thing["pipe"]:
lines.append(f"{thing['name']} 조합 미지정")
continue
names = [
item["name"] or item["key"]
for gid, item in slots
if not (thing["pipe"] and gid in ("", _PIPE_BODY_LINK))
]
lines += list(dict.fromkeys(names))
return lines
def _shape(index: int, kind: str, shape_data: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"culvert-note:{index}",
@@ -141,17 +146,10 @@ def _text(index: int, label: str, x: float, y: float, align: str = "left") -> di
)
def culvert_note_entities(
chainage_m: float,
culvert: dict[str, Any] | None,
design: dict[str, Any] | None,
station: str,
options: dict[str, Any] | None = None,
def _pipe_mark(
chainage_m: float, culvert: dict[str, Any] | None, design: dict[str, Any] | None
) -> list[dict[str, Any]]:
"""횡단배수 미리 넣음 — 이 측점에 놓인(주인) 배수관만 · 상자형 · 숨은 관 · 이웃 = 빈 목록.
`culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `options` = 그 관의 `pipe_points` 옵션.
"""
"""원형 마크 — TS `pipeMark`(주인 배수관만 · 상자형 · 숨은 관 · 이웃 = 없음)."""
if not isinstance(culvert, dict) or culvert.get("type") != "pipe":
return []
if culvert.get("body") or culvert.get("hidden_pipe"):
@@ -166,18 +164,7 @@ def culvert_note_entities(
f"Φ{_js_round(float(culvert.get('diameter_m') or 0.0) * 1000)}m/m",
f"L={_whole_metres(length)}",
]
chute = culvert.get("chute") if isinstance(culvert.get("chute"), dict) else {}
right = [
line
for line in (
station,
_side_line(culvert.get("inlet"), "유입"),
_side_line(culvert.get("outlet"), "유출"),
_chute_line(options or {}, chute.get("structure")),
)
if line
]
entities = [
return [
_shape(0, "Circle", {"center": {"x": cx, "y": cy}, "radius": radius}),
_shape(
1,
@@ -187,38 +174,84 @@ def culvert_note_entities(
"endPoint": {"x": cx, "y": cy - radius - _LINE},
},
),
]
entities += [
_text(2 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside)
]
entities += [_text(5 + i, line, 15, -_LINE * (i + 0.5)) for i, line in enumerate(right)]
return entities
] + [_text(2 + i, line, cx, cy + _LINE * (1 - i), "center") for i, line in enumerate(inside)]
def load_pipe_notes(project_root: Path) -> list[tuple[float, dict[str, Any], dict[str, Any]]]:
"""정본 `pipe_points.json` 의 배수관 — (관 누가거리, 세트, 옵션) · 관경 안 고름 = 관 없음."""
path = pipe_points_file(project_root)
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
def note_entities(
chainage_m: float,
culvert: dict[str, Any] | None,
design: dict[str, Any] | None,
station: str,
lines: list[str],
) -> list[dict[str, Any]]:
"""미리 넣음 — TS `noteEntities` 짝(원형 마크 + 측점 · 조합 줄 · 마크 없으면 왼쪽 끝부터).
`culvert` = 측점에 얹힌 세트(`attach_culvert_sets` 꼴) · `lines` = `structure_note_lines`.
"""
mark = _pipe_mark(chainage_m, culvert, design)
if not mark and not lines:
return []
pipes = []
for point in parse_pipe_points(document.get("points")):
x = 15 if mark else 0
start = len(mark)
return mark + [
_text(start + i, line, x, -_LINE * (i + 0.5)) for i, line in enumerate([station, *lines])
]
def _things(points: list[Any], structures: list[Any], names: dict[str, str]) -> list[Thing]:
"""글 줄 재료 — 관 지점 → 구조물 배치 차례(TS `loadNoteThings` 와 같은 차례)."""
things: list[Thing] = [
{
"name": names.get(point.facility, point.facility),
"pipe": point.facility == PIPE_FACILITY_PIPE,
"at": float(point.chainage_m),
"summary_item": point.summary_item,
"group_items": point.group_items,
}
for point in points
]
for item in structures:
at = item.chainage_m if item.chainage_m is not None else item.start_m
if not _number(at):
continue
things.append(
{
"name": names.get(item.type_id, item.type_id),
"pipe": False,
"at": float(at),
"summary_item": item.summary_item,
"group_items": item.group_items,
}
)
return things
def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]:
"""정본 재료 — (원형 마크 배수관 · 관경 안 고름 = 없음, 글 줄 재료 = 관 지점 + 구조물 배치)."""
try:
document = json.loads(pipe_points_file(project_root).read_text(encoding="utf-8"))
points = parse_pipe_points(document.get("points"))
except (OSError, json.JSONDecodeError):
points = []
pipes: list[Pipe] = []
for point in points:
options = dict(point.options or {})
if point.facility != PIPE_FACILITY_PIPE or options.get("pipe_diameter_mm") in (None, ""):
continue
spec = {**_culvert_set(options), "chainage_m": float(point.chainage_m)}
pipes.append((float(point.chainage_m), spec, options))
return pipes
_revision, structures = load_structures(str(project_root))
names = {kind.type_id: kind.name for kind in load_structure_types()}
return pipes, _things(points, structures, names)
def with_culvert_note(
def with_structure_note(
design: dict[str, Any] | None,
chainage_m: float,
pipes: list[tuple[float, dict[str, Any], dict[str, Any]]],
sources: tuple[list[Pipe], list[Thing]],
station: str,
) -> dict[str, Any] | None:
"""CAD 칸이 없고 옛 글도 없는 주인 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로.
"""CAD 칸이 없고 옛 글도 없는 측점 — 미리 넣음을 칸으로 얹은 사본 · 그 밖 = 그대로.
설계가 없는 측점(dict 아님)은 그대로 — 표 · 계획선 셈이 설계 없음으로 가는 길을 안 바꿈.
"""
@@ -226,11 +259,11 @@ def with_culvert_note(
return design
if str(design.get("structure_note") or "").strip():
return design
pipes, things = sources
near = [pipe for pipe in pipes if abs(pipe[0] - chainage_m) < PIPE_MATCH_M]
if not near:
return design
_at, spec, options = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))
entities = culvert_note_entities(chainage_m, spec, design, station, options)
spec = min(near, key=lambda pipe: abs(pipe[0] - chainage_m))[1] if near else None
lines = structure_note_lines(things, chainage_m)
entities = note_entities(chainage_m, spec, design, station, lines)
if not entities:
return design
return {**design, "structure_note_cad": {"entities": entities}}
@@ -26,8 +26,10 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
load_pipe_notes,
with_culvert_note,
Pipe,
Thing,
load_note_sources,
with_structure_note,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import (
station_no_label as station_no_label_upper,
@@ -390,11 +392,12 @@ def _cross_section_input(
chainage: int | float,
design: dict[str, Any] | None,
overlay: tuple[float, float, float, float] | None = None,
pipes: list[tuple[float, dict[str, Any], dict[str, Any]]] | None = None,
pipes: tuple[list[Pipe], list[Thing]] | None = None,
) -> dict[str, Any] | None:
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다.
`pipes` = 정본 배수관(`load_pipe_notes`) — CAD 칸 없는 주인 측점에 미리 넣음(260-4)."""
`pipes` = 정본 재료(`load_note_sources` · 배수관 · 구조물) — CAD 칸 없는 측점에
미리 넣음(260-4 · 265-2)."""
path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage)
if not path.is_file():
return None
@@ -405,7 +408,9 @@ def _cross_section_input(
"chainage": chainage,
"source": source,
# 표 · 계획선은 저장 설계 그대로 · 그림만 미리 넣음 칸을 얹은 사본(260-4)
"design": with_culvert_note(design, at, pipes or [], station_no_label_upper(at, interval)),
"design": with_structure_note(
design, at, pipes or ([], []), station_no_label_upper(at, interval)
),
"design_line": _cross_design_line(longitudinal_path, source, design),
"quantity_table": _quantity_table(source, design),
"title": station_no_label(float(source.get("chainage_m", chainage)), interval),
@@ -428,7 +433,7 @@ def _read_cross_sheet(
if sheet is None:
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
longitudinal = _read_json(longitudinal_path)
pipes = load_pipe_notes(project_root)
pipes = load_note_sources(project_root)
sections = [
section
for section in (
+6 -10
View File
@@ -6,7 +6,7 @@
* DOM 만 만들고 상태는 갖지 않는다 — 고르기 동작은 인자로 받은 콜백에 넘긴다.
* ========================================================================== */
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
@@ -79,7 +79,7 @@ export function buildDrawingSidePanel(
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "b07-drawing-button";
button.className = "b07-drawing-button ui-panel-btn";
button.dataset.drawingId = drawing.id;
button.dataset.confirmed = String(drawing.confirmed);
const name = document.createElement("span");
@@ -114,7 +114,7 @@ export function buildDrawingSidePanel(
if (!drawing) {
const placeholder = document.createElement("button");
placeholder.type = "button";
placeholder.className = "b07-drawing-button";
placeholder.className = "b07-drawing-button ui-panel-btn";
placeholder.disabled = true;
placeholder.title = "준비 중";
placeholder.dataset.pending = "true";
@@ -132,14 +132,10 @@ export function buildDrawingSidePanel(
panel.append(button);
continue;
}
const section = document.createElement("section");
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(B04~B06과 통일, 2026-08-06 사용자 지시).
section.className = "b07-drawing-group ui-collapsible ui-sidebar-section";
// 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식).
const section = createPanelCard(`${group.label} ${items.length}`);
section.classList.add("b07-drawing-group");
if (group.kind) section.dataset.kind = group.kind;
const sectionTitle = document.createElement("h3");
sectionTitle.className = "ui-collapsible__title";
sectionTitle.textContent = `${group.label} ${items.length}`;
section.append(sectionTitle);
if (cross) {
// 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만.
for (const standard of drawings.filter((item) => item.kind === "cross_standard"))
@@ -9,6 +9,7 @@
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createPanelCard } from "@ui/ui_template_collapsible";
import { createSelectField, showToast } from "@ui/ui_template_elements";
import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields";
import {
@@ -257,9 +258,10 @@ export function buildPlanSection(
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
onManage?: () => void,
): HTMLElement {
const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section");
// 컨테이너 = 좌측 패널 공용 카드(269)
const section = createPanelCard(`평면도 ${items.length}`);
section.classList.add("b07-drawing-group", "b07-plan");
section.dataset.kind = "plan";
section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`));
for (const { kindId, name, sheets } of groupPlanSheets(items)) {
const block = el("div", "b07-plan-kind");
@@ -273,7 +275,7 @@ export function buildPlanSection(
if (onManage) {
// 종류 · 대피소 자리 · 장 미리보기는 모달 한 곳(좌측 공간 · 접근 혼돈 — 130-1).
const manage = el("button", "b07-plan-tools__button b07-plan-manage-open", "평면도 관리");
const manage = el("button", "ui-panel-btn b07-plan-manage-open", "평면도 관리");
manage.type = "button";
manage.addEventListener("click", onManage);
section.append(manage);
+2 -44
View File
@@ -40,15 +40,6 @@
font-size: var(--text-body-sm);
}
.b07-drawing-group {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
/* 테두리 색·두께는 공통 ui-sidebar-section이 준다 — 여기선 형태(라운드·안쪽 여백)만. */
border-radius: var(--radius-lg);
padding: var(--spacing-8);
}
/* 횡단도는 2열 배치 — 단, **칸이 좁아지면 1열로 접는다**(2026-09-08).
좌측 패널이 320 고정이던 동안은 2열이 늘 128.8px 라 문제가 없었으나, 공용
오버레이가 좁은 폭에서 패널을 280·227·182 로 물리면서 칸이 108.8 → 82.4 → 60px
@@ -62,13 +53,6 @@
gap: var(--spacing-4);
}
.b07-drawing-group h3 {
margin: var(--spacing-8) 0 var(--spacing-4);
color: var(--color-text-muted);
font-size: var(--text-caption);
font-weight: 600;
}
/* 아직 내용이 없는 도면 — 눌리기는 하고 도각만 나온다(2026-09-01). 옅게 두되
못 누르는 것처럼 보이지 않게 커서는 그대로 둔다. */
.b07-drawing-button[data-pending="true"] {
@@ -94,22 +78,13 @@
/* 격자 칸의 기본 최소폭은 글자 길이라, 이름이 길면 칸이 패널을 밀어내 단추가
오른쪽으로 튀어나갔다(2026-09-04 실측 14px). 0으로 풀면 이름이 `…`로 잘린다. */
min-width: 0;
min-height: 34px;
padding: var(--spacing-4) var(--spacing-8);
/* 경계가 뚜렷하도록 배경색과 유사하지만 조금 짙은 톤 + 은은한 테두리 */
border: 1px solid color-mix(in srgb, var(--color-border) 55%, transparent);
/* 확정 여부를 나타내는 좌측 색 띠 (미확정: 투명) */
/* 크기 · 테두리 · 바탕 · 글자 = 공용 `.ui-panel-btn`(269) · 여기선 확정 여부 좌측 색 띠(미확정: 투명)만 */
border-left: 3px solid transparent;
border-radius: var(--radius-buttons);
background: color-mix(in srgb, var(--color-surface) 84%, #000);
color: var(--color-text);
cursor: pointer;
text-align: center;
}
.b07-drawing-button__name {
overflow: hidden;
font-size: var(--text-body-sm);
white-space: nowrap;
text-overflow: ellipsis;
}
@@ -227,7 +202,7 @@
/* 횡단 장 배치(153-1 · 212-3) — 횡단도 묶음 맨 아래 전체 폭 · 한 줄에 한 일:
배치 열 · 행 / [이 장] [모든 장] / 표 배율 [배율 적용] / [안내선 숨기기].
단추 · 숫자 칸 = 줄 위 작은 단추 한 규격(`--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비 · 오류 자리는 접음. */
단추 · 숫자 칸 크기 = 좌측 패널 공용 양식(`.ui-panel-form` · 269) · 한 줄 안 같은 너비. */
.b07-cross-layout-slot {
grid-column: 1 / -1;
}
@@ -260,17 +235,8 @@
gap: var(--spacing-4);
}
.b07-cross-layout .ui-btn,
.b07-cross-layout .ui-field .ui-input {
height: auto;
padding: var(--btn-bar-padding);
font-size: var(--btn-bar-font-size);
line-height: 1;
}
.b07-cross-layout .ui-field__label {
margin: 0;
font-size: var(--btn-bar-font-size);
white-space: nowrap;
}
@@ -279,10 +245,6 @@
min-width: 0;
}
.b07-cross-layout .ui-field__error {
display: none;
}
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
.b07-drawing-list__bypass {
margin: var(--spacing-4) 0 0;
@@ -315,10 +277,6 @@
font-size: var(--text-caption);
}
.b07-plan-manage-open {
margin-top: var(--spacing-4);
}
/* [평면도 관리] 모달(130-1) — 왼쪽 종류 · 자리 · 장 목록 / 오른쪽 미리보기 · 공용 모달에 자기 class 로 큰 창 */
.ui-modal.b07-plan-manage {
width: min(1320px, 96vw);
@@ -140,7 +140,6 @@ const STUBS = {
return cad;
},
},
"@ui/ui_template_collapsible": { attachCollapsible: () => {} },
"@ui/ui_template_locale": {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
@@ -233,6 +232,9 @@ function loadTs(file) {
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
// 공용 카드(269) = 진짜 모듈(가짜 그릇 위에서 돎)
if (request === "@ui/ui_template_collapsible")
return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts"));
if (request === "./B07_DesignDetail_Api_Fetch") return API;
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
throw new Error(`stub 없음: ${request}`);
@@ -78,7 +78,9 @@ function loadTs(file) {
...loadTs(path.join(UI, "ui_template_elements.ts")),
showToast: (message, kind) => toasts.push([kind, message]),
};
if (request === "@ui/ui_template_collapsible") return { attachCollapsible: () => {} };
// 공용 카드(269) = 진짜 모듈
if (request === "@ui/ui_template_collapsible")
return loadTs(path.join(UI, "ui_template_collapsible.ts"));
if (request === "@ui/ui_template_locale")
return {
currentLanguageIndex: 0,
+41 -19
View File
@@ -7,12 +7,21 @@ const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
// 인자 `load` = 표준입력 {pipes, structures, types} 를 세션 · 서버 흉내로 `loadNoteThings`(265-2)
const DATA = { pipes: [], structures: [], types: [] };
const STUBS = {
"@ui/cad_host/cad_host": { createCadHost: () => ({}) },
"../A00_Common/b_page_state": { readState: () => null, writeState: () => undefined },
"../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51 },
"../B04_PreProcess/B04_PreProcess_Api_Fetch": { fetchDetailPipePoints: async () => ({}) },
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
fetchDetailPipePoints: async () => ({ pipe_points: DATA.pipes }),
},
"../B05_Profile/B05_Profile_Api_Pipes_Draft": { readPendingPipes: () => null },
"../B05_Profile/B05_Profile_Api_Structures": {
fetchStructures: async () => ({ structures: DATA.structures }),
fetchStructureTypes: async () => DATA.types,
readPendingStructures: () => null,
},
};
function loadTs(file) {
@@ -28,10 +37,13 @@ function loadTs(file) {
return box.exports;
}
// 관 길이 정수(265-3) = 실제 `pipeLengthWhole`(타입 import 만 있는 파일)
// 관 길이 정수(265-3) = 실제 `pipeLengthWhole` · 조합 참조 읽기(265-2) = 실제(둘 다 import 없음)
STUBS["./B06_Section_UI_Cross_Culvert_Const"] = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Const.ts"),
);
STUBS["../common_util/common_util_summary_item"] = loadTs(
path.join(ROOT, "common_util", "common_util_summary_item.ts"),
);
const cad = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail_Cad.ts"));
const wall = (role, form, height, length) => ({
@@ -58,7 +70,6 @@ const section = (spec, design = { pipe_length_m: 22 }, chainage = 1433) => ({
culvert: spec,
design,
});
const CHUTE = { chute_add_1_structure: "돌붙임", chute_add_1_bond: "찰붙임", chute_add_1_area: 10 };
/** 엔티티 → 비교하기 쉬운 꼴(종류 · 글 · 자리 · 색). */
const brief = (entities) =>
@@ -71,11 +82,12 @@ const brief = (entities) =>
color: entity.lineColor,
layer: entity.layerId,
}));
const run = (sec, options) => brief(cad.culvertNoteEntities(sec, "NO.71+13", options));
const run = (sec, lines = []) => brief(cad.noteEntities(sec, "NO.71+13", lines));
const LINES = ["기슭막이 찰쌓기 2.0m", "돌붙임 찰붙임"];
const out = {
full: run(section(culvert()), CHUTE),
noLength: run(section(culvert(), {}), {}),
full: run(section(culvert()), LINES),
noLength: run(section(culvert(), {})),
basin: run(
section(
culvert({
@@ -88,9 +100,9 @@ const out = {
},
}),
),
{},
[],
),
chuteFallback: run(section(culvert({ chute: { structure: "도수로", thickness_m: 0.45 } })), {}),
chuteFallback: run(section(culvert({ chute: { structure: "도수로", thickness_m: 0.45 } })), []),
noSides: run(
section(
culvert({
@@ -98,12 +110,14 @@ const out = {
outlet: { role: "outlet", structure: "" },
}),
),
{},
[],
),
none: run(section(undefined), CHUTE),
ford: run(section(culvert({ body: "ford" })), CHUTE),
hidden: run(section(culvert({ hidden_pipe: true })), CHUTE),
neighbour: run(section(culvert(), { pipe_length_m: 22 }, 1440), CHUTE),
none: run(section(undefined)),
ford: run(section(culvert({ body: "ford" }))),
hidden: run(section(culvert({ hidden_pipe: true }))),
neighbour: run(section(culvert(), { pipe_length_m: 22 }, 1440)),
// 마크 없이 조합 줄만(구조물 측점 · 265-2) = 측점부터 왼쪽 끝
linesOnly: run(section(undefined), LINES),
legacy: brief(cad.legacyNoteEntities("\nL형 측구 \n\n집수정 보강\n")),
legacyEmpty: cad.legacyNoteEntities(" ").length,
station: [
@@ -112,13 +126,21 @@ const out = {
cad.stationNoLabel(1439.97, 20),
],
};
// 거울(260-4) — 시험이 표준입력으로 준 같은 입력 [{section, station, options, chainage, interval}]
// → TS 엔티티 · 측점 이름(파이썬 짝 `_Engine_Cad_CulvertNote` 와 대조)
// 거울(260-4 · 265-2) — 시험이 표준입력으로 준 같은 입력 [{section, station, things, chainage, interval}]
// → TS 조합 줄 · 엔티티 · 측점 이름(파이썬 짝 `_Engine_Cad_CulvertNote` 와 대조)
if (process.argv[2] === "mirror") {
const cases = JSON.parse(fs.readFileSync(0, "utf8"));
const mirrored = cases.map((item) => ({
entities: cad.culvertNoteEntities(item.section, item.station, item.options),
station: cad.stationNoLabel(item.chainage, item.interval),
}));
const mirrored = cases.map((item) => {
const lines = cad.structureNoteLines(item.things ?? [], item.section.chainage_m);
return {
lines,
entities: cad.noteEntities(item.section, item.station, lines),
station: cad.stationNoLabel(item.chainage, item.interval),
at: cad.thingsAt(item.things ?? [], item.section.chainage_m).length,
};
});
process.stdout.write(JSON.stringify(mirrored));
} else if (process.argv[2] === "load") {
Object.assign(DATA, JSON.parse(fs.readFileSync(0, "utf8")));
cad.loadNoteThings("p").then((things) => process.stdout.write(JSON.stringify(things)));
} else process.stdout.write(JSON.stringify(out));
+2 -1
View File
@@ -93,7 +93,8 @@ def test_상세_큰_창_공용_막_그림_글_표_순서():
with open(os.path.join(base, "B06_Section_UI_Style_Cross_Detail.css"), encoding="utf-8") as f:
css = f.read().replace(chr(13), "")
assert "wide: true" in src and "backdropClass" not in src
assert "body.append(cad, note, tableBox)" in src
# 265-1 — 그림 → 표 → CAD 칸(글 칸 자리)
assert "body.append(cad, tableBox, noteBox)" in src
# 막 없음 — 259-3 표 강조 바깥 줄(누름 통과)만 pointer-events: none
backdrop = css.split(".b06-detail__pick-outline {")[0]
assert "pointer-events: none" not in backdrop and "b06-detail__backdrop" not in css
@@ -4,7 +4,7 @@
못박는 것
- 줄 = 「배치 열 · 행」 · 「[이 장] [모든 장]」 · 「표 배율 [배율 적용]」
· [안내선 숨기기](부르는 쪽).
- 단추 · 숫자 칸 · 이름 = 줄 위 작은 단추 토큰(187 `--btn-bar-*` · 높이 16) · 한 줄 안 같은 너비.
- 단추 · 숫자 칸 · 이름 = 좌측 패널 공용 양식(269 `.ui-panel-form`) · 한 줄 안 같은 너비.
- 옛 한 줄 감기(flex-wrap) · 숫자 칸 48px 고정 없음.
- [안내선 숨기기] 뒤 남는 검은 선 = 인쇄 칸선(도각 층 도면 개체 · 회청 `#8ea0b5`
· 밝은 바탕에서 짙게 그려짐) — 안내선(겹판)과 다른 것이라 지우지 않음 · 단추는 겹판만 끔.
@@ -40,16 +40,13 @@ def _decl(block: str, prop: str) -> str:
def test_단추_숫자_칸_한_규격_토큰():
shared = _block(".b07-cross-layout .ui-btn,\n.b07-cross-layout .ui-field .ui-input")
assert _decl(shared, "padding") == "var(--btn-bar-padding)"
assert _decl(shared, "font-size") == "var(--btn-bar-font-size)"
assert _decl(shared, "line-height") == "1"
assert _decl(shared, "height") == "auto" # 공용 작은 칸 30px 를 풀어 단추 높이에 맞춤
# 269 — 크기는 좌측 패널 공용 양식(`.ui-panel-form`)이 줌 · B07 덧칠(줄 위 작은 단추 토큰) 없음
assert ".b07-cross-layout .ui-btn" not in CSS
assert ".b07-cross-layout .ui-field .ui-input {" in CSS # 폭만
assert "height" not in _block(".b07-cross-layout .ui-field .ui-input")
label = _block(".b07-cross-layout .ui-field__label")
assert _decl(label, "font-size") == "var(--btn-bar-font-size)"
# 토큰은 theme 한 곳(187)
assert "--btn-bar-padding: 1px var(--spacing-8);" in THEME
assert "--btn-bar-font-size: var(--text-caption);" in THEME
assert "font-size" not in label
assert "createPanelCard(" in PANELS # 배치 칸이 사는 횡단도 묶음 = 공용 카드
def test_한_줄에_한_일_같은_너비():
+91 -64
View File
@@ -2,8 +2,9 @@
"""PLAN 260 — B06 [상세] 표 위 CAD 칸(`structure_note_cad`) → B07 횡단도 표 위.
- 260-1 칸: 스키마 · 재계산 남김 · 세션 초안 → [저장] patch.
- 260-2 미리 넣음: 횡단배수 주인 측점만 · 관 · 유입 · 유출 · 도수로 · 빈 구조물 · 옛 글 옮김
(`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림).
- 260-2 미리 넣음: 횡단배수 주인 측점 원형 마크 · 빈 구조물 · 옛 글 옮김
(`helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림) · 오른쪽 글 = 구조물 조합 줄(265-2 ·
`test_265_note_cad.py`) — 옛 관보호공 · 관유출부보강 짜 맞춘 글은 버림.
./venv/Scripts/python.exe -m pytest resources/tester/test_260_note_cad.py -q
"""
@@ -101,7 +102,7 @@ def _labels(entities: list[dict]) -> list[str]:
def test_관_유입_유출_도수로_한_벌(out):
"""그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 관보호공 둘 · 관유출부보강."""
"""그림(162-21) 꼴 — 원 안 세 줄 · 오른쪽 측점 · 구조물 조합 줄(265-2)."""
full = out["full"]
assert [entity["type"] for entity in full[:2]] == ["Circle", "Line"]
assert _labels(full) == [
@@ -109,30 +110,32 @@ def test_관_유입_유출_도수로_한_벌(out):
"Φ1200m/m",
"L=22m",
"NO.71+13",
"관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m",
"관보호공(유출) 기슭막이(메,H=2.0) L=10.0m",
"관유출부보강(돌붙임,찰) 10m2",
"기슭막이 찰쌓기 2.0m",
"돌붙임 찰붙임",
]
# 원 안 = 가운데 맞춤 · 원 중심 x · 오른쪽 = 왼쪽 맞춤 · 줄 3.0 간격 · 붉은 계열 · 기본 층
inside, right = full[2:5], full[5:]
assert {(e["align"], e["x"]) for e in inside} == {("center", full[0]["x"])}
assert [e["y"] for e in right] == [-1.5, -4.5, -7.5, -10.5]
assert [e["y"] for e in right] == [-1.5, -4.5, -7.5]
assert {e["x"] for e in right} == {15}
assert {e["align"] for e in right} == {"left"}
assert {e["color"] for e in full} == {"#ff7f7f"} and {e["layer"] for e in full} == {"기본"}
def test_관_길이_없으면_L_뒤_빈칸(out):
assert _labels(out["noLength"])[2] == "L="
# 도수로 상자가 없으면 관유출부보강 줄 없음
assert not any(label.startswith("관유출부보강") for label in _labels(out["noLength"]))
# 조합 줄이 없으면 측점 줄까지
assert _labels(out["noLength"])[3:] == ["NO.71+13"]
def test_유입_집수정_은_형태_높이_길이(out):
assert "관보호공(유입) 집수정(L형,H=1.7) L=2.0m" in _labels(out["basin"])
# 265-2 — 보호공 짜 맞춘 글 버림(조합 줄로) · 원형 마크 · 측점만
assert _labels(out["basin"]) == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"]
def test_도수로_옵션이_없으면_세트_구조만(out):
assert _labels(out["chuteFallback"])[-1] == "관유출부보강(도수로) m2"
# 265-2 — 관유출부보강 짜 맞춘 글 버림
assert not any(label.startswith("관유출부보강") for label in _labels(out["chuteFallback"]))
def test_보호공을_안_고르면_관과_측점만(out):
@@ -141,10 +144,16 @@ def test_보호공을_안_고르면_관과_측점만(out):
@pytest.mark.parametrize("case", ["none", "ford", "hidden", "neighbour"])
def test_횡단배수_주인_측점이_아니면_빈값(out, case):
"""구조물 없음 · 상자형(세월교 · 박스) · 숨은 관(독립 기슭막이) · 이웃 측점 = 빈 도면."""
"""구조물 없음 · 상자형(세월교 · 박스) · 숨은 관 · 이웃 측점 = 빈 도면(줄 없음)."""
assert out[case] == []
def test_마크_없이_조합_줄만이면_측점부터_왼쪽_끝(out):
lines = out["linesOnly"]
assert _labels(lines) == ["NO.71+13", "기슭막이 찰쌓기 2.0m", "돌붙임 찰붙임"]
assert {e["x"] for e in lines} == {0} and [e["y"] for e in lines] == [-1.5, -4.5, -7.5]
def test_옛_글은_줄마다_Text_로_옮김(out):
legacy = out["legacy"]
assert _labels(legacy) == ["L형 측구", "집수정 보강"]
@@ -300,10 +309,11 @@ def test_CAD_칸이_없으면_옛_글_줄_그대로():
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402
culvert_note_entities,
load_pipe_notes,
load_note_sources,
note_entities,
station_no_label,
with_culvert_note,
structure_note_lines,
with_structure_note,
)
_OPTIONS = {
@@ -323,36 +333,44 @@ _OPTIONS = {
_NO_CHUTE = {k: v for k, v in _OPTIONS.items() if not k.startswith("chute")}
def _case(options: dict, pipe_at: float, chainage: float, design: dict | None, **spec) -> dict:
def _item(key: str, name: str) -> dict:
return {"layer": "system", "owner": None, "key": key, "name": name}
# 관 조합 — 본체(「관」) · 유입 · 유출 · 도수로(265-2 · 본체 줄은 원형 마크가 대신)
_PIPE_THING = {
"name": "배관",
"pipe": True,
"at": 1433.0,
"group_items": {
"grv6lny": _item("SS000001", "흄관 1200"),
"gnbf9m9": _item("SS000055", "기슭막이 메쌓기 1.2m 메쌓기 기초무"),
"gchuteadd1": _item("SS000070", "돌붙임 찰붙임"),
},
}
def _case(
options: dict, pipe_at: float, chainage: float, design: dict | None, things=(), **spec
) -> dict:
culvert = {**_culvert_set(options), "chainage_m": pipe_at, **spec}
return {
"section": {"chainage_m": chainage, "culvert": culvert, "design": design},
"station": station_no_label(chainage, 20.0),
"options": options,
"things": list(things),
"chainage": chainage,
"interval": 20.0,
}
_MIRROR_CASES = [
_case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}),
_case(_OPTIONS, 1433.0, 1433.0, {"pipe_length_m": 22}, [_PIPE_THING]),
_case(_NO_CHUTE, 1433.0, 1433.0, {}),
_case(
{
**_NO_CHUTE,
"inlet_type": "집수정",
"inlet_structure": "L",
"inlet_basin_height_m": 1.7,
"inlet_basin_length_m": 2,
},
1433.0,
1433.0,
{"pipe_length_m": 9.5},
),
_case({**_NO_CHUTE, "chute_add_2_structure": "도수로", "chute_add_2_area": ""}, 80, 80, None),
_case({**_OPTIONS, "pipe_diameter_mm": 800, "chute_add_1_area": 12.345}, 12.5, 12.5, {}),
_case({**_OPTIONS, "inlet_revet_form": "콘크리트"}, 440.241, 440.0, {"pipe_length_m": 7}),
_case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}),
_case(_NO_CHUTE, 1433.0, 1433.0, {"pipe_length_m": 9.5}),
_case(_NO_CHUTE, 80, 80, None, [{**_PIPE_THING, "at": 80.0}]),
_case({**_OPTIONS, "pipe_diameter_mm": 800}, 12.5, 12.5, {}, [{**_PIPE_THING, "at": 12.5}]),
_case(_OPTIONS, 440.241, 440.0, {"pipe_length_m": 7}, [{**_PIPE_THING, "at": 440.241}]),
_case(_OPTIONS, 1433.0, 1440.0, {"pipe_length_m": 22}, [_PIPE_THING]),
_case(_OPTIONS, 1433.0, 1433.0, {}, body="ford"),
_case(_OPTIONS, 1433.0, 1433.0, {}, hidden_pipe=True),
]
@@ -362,33 +380,35 @@ def _without_id(entities: list[dict]) -> list[dict]:
return [{k: v for k, v in entity.items() if k != "id"} for entity in entities]
def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티():
def _mirror(cases: list[dict]) -> list[dict]:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"],
input=json.dumps(_MIRROR_CASES, ensure_ascii=False),
input=json.dumps(cases, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
mirrored = json.loads(proc.stdout)
return json.loads(proc.stdout)
def test_거울_TS_와_파이썬_짝은_같은_입력에_같은_엔티티():
mirrored = _mirror(_MIRROR_CASES)
assert len(mirrored) == len(_MIRROR_CASES)
for case, ts in zip(_MIRROR_CASES, mirrored, strict=True):
section = case["section"]
py = culvert_note_entities(
section["chainage_m"],
section["culvert"],
section["design"],
case["station"],
case["options"],
lines = structure_note_lines(case["things"], section["chainage_m"])
assert lines == ts["lines"], case["station"]
py = note_entities(
section["chainage_m"], section["culvert"], section["design"], case["station"], lines
)
assert _without_id(py) == _without_id(ts["entities"]), case["station"]
assert station_no_label(case["chainage"], case["interval"]) == ts["station"]
# 대조가 빈 목록끼리만이 아님 — 주인 측점 여섯은 그림이 있음 · 이웃 · 세월교 · 숨은 관 = 빈값
assert [bool(item["entities"]) for item in mirrored] == [True] * 6 + [False] * 3
labels = [e["shapeData"].get("label") for e in mirrored[4]["entities"]]
assert "Φ800m/m" in labels and "관유출부보강(돌붙임,찰) 12.35m2" in labels
assert "Φ800m/m" in labels and "돌붙임 찰붙임" in labels and "흄관 1200" not in labels
def _write_pipes(root: Path, points: list[dict]) -> None:
@@ -406,10 +426,16 @@ def test_정본_배수관만_읽음(tmp_path):
{"chainage_m": 90.0, "lonlat": [0, 0], "facility": "ford_bridge", "options": {}},
],
)
pipes = load_pipe_notes(tmp_path)
pipes, things = load_note_sources(tmp_path)
assert [pipe[0] for pipe in pipes] == [1433.0] # 관경 안 고름 · 세월교 = 빼
assert pipes[0][1]["chainage_m"] == 1433.0 and pipes[0][2] == _OPTIONS
assert load_pipe_notes(tmp_path / "없음") == []
# 글 줄 재료 = 관 지점 모두(측점 차례 · 시설 이름 = 구조물정의 종류 이름)
assert [(t["name"], t["pipe"], t["at"]) for t in things] == [
("배관", True, 80.0),
("세월교", False, 90.0),
("배관", True, 1433.0),
]
assert load_note_sources(tmp_path / "없음") == ([], [])
def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_그대로(tmp_path):
@@ -417,37 +443,38 @@ def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_
tmp_path,
[{"chainage_m": 1433.0, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}],
)
pipes = load_pipe_notes(tmp_path)
sources = load_note_sources(tmp_path)
design = {"pipe_length_m": 22, "paved": False}
seeded = with_culvert_note(design, 1433.0, pipes, "NO.71+13")
seeded = with_structure_note(design, 1433.0, sources, "NO.71+13")
assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜
labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]]
assert labels[2:] == [
"배수관",
"Φ1200m/m",
"L=22m",
"NO.71+13",
"관보호공(유입) 기슭막이(찰,H=2.0) L=10.0m",
"관보호공(유출) 기슭막이(메,H=2.0) L=10.0m",
"관유출부보강(돌붙임,찰) 10m2",
]
# 관 조합 미지정 = 원형 마크 · 측점만(배관은 글 줄 없음)
assert labels[2:] == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"]
cleared = {**design, "structure_note_cad": {"entities": []}}
assert with_culvert_note(cleared, 1433.0, pipes, "x") is cleared
assert with_structure_note(cleared, 1433.0, sources, "x") is cleared
old = {**design, "structure_note": "옛 글"}
assert with_culvert_note(old, 1433.0, pipes, "x") is old
assert with_culvert_note(None, 1433.0, pipes, "x") is None
assert with_culvert_note(design, 1440.0, pipes, "x") is design
assert with_structure_note(old, 1433.0, sources, "x") is old
assert with_structure_note(None, 1433.0, sources, "x") is None
assert with_structure_note(design, 1440.0, sources, "x") is design
def test_B07_은_안_연_측점에_미리_넣음을_표_위에_그림(tmp_path):
_write_pipes(
tmp_path,
[{"chainage_m": 20.2, "lonlat": [0, 0], "facility": "pipe", "options": _OPTIONS}],
[
{
"chainage_m": 20.2,
"lonlat": [0, 0],
"facility": "pipe",
"options": _OPTIONS,
"group_items": {"g9lq9uk": _item("SS000055", "기슭막이 메쌓기 1.2m")},
}
],
)
design = with_culvert_note({"pipe_length_m": 22}, 20.0, load_pipe_notes(tmp_path), "NO.1")
design = with_structure_note({"pipe_length_m": 22}, 20.0, load_note_sources(tmp_path), "NO.1")
drawn = _draw(design)
labels = [e["shapeData"]["label"] for e in drawn["entities"] if e["type"] == "Text"]
assert "NO.1" in labels and "관유출부보강(돌붙임,찰) 10m2" in labels
assert "NO.1" in labels and "기슭막이 메쌓기 1.2m" in labels
plain_top = _table(_draw({"pipe_length_m": 22}))["shapeData"]["origin"]["y"]
drop = plain_top - _table(drawn)["shapeData"]["origin"]["y"]
assert drop == pytest.approx(cross_note_block_height(design)) and drop > 15
+176 -14
View File
@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
"""PLAN 265 — B06 [상세] 표 위 CAD 칸 · 접기 · 차례 · 구조물 글 · 관 길이 정수.
- 265-1 상세 창 차례 = 그림 → 표 → CAD 칸 · CAD 칸 접기(일반 측점 접힘 · 구조물 측점 폄).
- 265-2 미리 넣는 글 = 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 · 배관 = 원형 마크
(TS = 파이썬 짝 · `helper_260_note_cad.cjs` 가 실제 TS 를 Node 로 돌림).
- 265-3 원형 마크 L = 사사오입 정수(TS `pipeLengthWhole` = 파이썬 짝 같은 값).
./venv/Scripts/python.exe -m pytest resources/tester/test_265_note_cad.py -q
@@ -17,19 +20,23 @@ PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from B06_Section.B06_Section_Engine_Culvert import _culvert_set # noqa: E402
from B05_Profile.B05_Profile_Structures_Repository import structures_file_path # noqa: E402
from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file # noqa: E402
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402
culvert_note_entities,
load_note_sources,
note_entities,
structure_note_lines,
with_structure_note,
)
HERE = Path(__file__).resolve().parent
_PIPE = {"pipe_diameter_mm": 800}
def _mirror(cases: list[dict]) -> list[dict]:
def _helper(mode: str, payload) -> list:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_260_note_cad.cjs"), "mirror"],
input=json.dumps(cases, ensure_ascii=False),
["node", str(HERE / "helper_260_note_cad.cjs"), mode],
input=json.dumps(payload, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
@@ -43,6 +50,168 @@ def _labels(entities: list[dict]) -> list[str]:
return [e["shapeData"]["label"] for e in entities if e["type"] == "Text"]
def _strip(entities: list[dict]) -> list[dict]:
return [{k: v for k, v in e.items() if k != "id"} for e in entities]
def _case(things: list, chainage: float, culvert: dict | None, design: dict) -> dict:
return {
"section": {"chainage_m": chainage, "culvert": culvert, "design": design},
"station": "NO.2",
"things": things,
"chainage": chainage,
"interval": 20.0,
}
def _read(rel: str) -> str:
return (PROJECT_ROOT / rel).read_text(encoding="utf-8")
# ── 265-1 차례 · 접기 ──────────────────────────────────────────────────
def test_상세_창_차례는_그림_표_CAD_칸():
detail = _read("B06_Section/B06_Section_UI_Cross_Detail.ts")
assert "body.append(cad, tableBox, noteBox);" in detail
# CAD 칸 = 공용 접기 꼴(머리 한 줄 · 캐럿) · 펼 때 한 번 만듦
assert 'noteBox.className = "b06-detail__notebox ui-collapsible";' in detail
assert 'noteHead.className = "b06-detail__notehead ui-collapsible__title";' in detail
assert 'if (!noteBox.classList.toggle("is-collapsed")) openNote();' in detail
def test_열_때_기본_일반_측점_접힘_구조물_측점_폄():
detail = _read("B06_Section/B06_Section_UI_Cross_Detail.ts")
start = detail.index('noteBox.classList.add("is-collapsed");')
rule = detail[start : detail.index("const cad = ", start)]
# 접힘으로 시작 → 그 측점에 놓인 구조물(`thingsAt`)이 있으면 폄 · 사용자가 먼저 누르면 그대로
assert "thingsAt(list, section.chainage_m).length" in rule
assert 'noteBox.classList.remove("is-collapsed");' in rule and "touched" in rule
# 판정 = 놓인 구조물 수(구조물 측점 1 이상 · 일반 측점 0) — 아래 거울 시험 `at`
[structure, plain] = _helper(
"mirror",
[_case(_THINGS, 40.0, None, {}), _case(_THINGS, 20.0, None, {})],
)
assert structure["at"] == 5 and plain["at"] == 0
# ── 265-2 구조물 조합 줄 ───────────────────────────────────────────────
_A = {"layer": "system", "owner": None, "key": "SS000055", "name": "기슭막이 메쌓기 1.2m 기초무"}
_B = {"layer": "system", "owner": None, "key": "SS000070", "name": "돌붙임 찰붙임"}
_C = {"layer": "company", "owner": "3", "key": "SC3-000001", "name": "옹벽 콘크리트 H=2.0"}
_BODY = {"layer": "system", "owner": None, "key": "SS000001", "name": "흄관 1200"}
_THINGS = [
# 배관 = 본체(옛 summary_item · 「관」 grv6lny) 줄 없음 · 보호공 · 도수로 조합만
{
"name": "배관",
"pipe": True,
"at": 40.2,
"summary_item": _BODY,
"group_items": {"grv6lny": _BODY, "gnbf9m9": _A, "gchuteadd1": _B},
},
# 옹벽 = 본체 + 그룹(같은 조합 = 한 줄)
{"name": "옹벽", "pipe": False, "at": 40.0, "summary_item": _C, "group_items": {"gx": _C}},
# 조합 미지정 구조물
{"name": "돌쌓기(찰)", "pipe": False, "at": 39.6},
# 미지정 배관(원형 마크가 대신 · 줄 없음) · 모양 틀린 참조 = 미지정
{"name": "배관", "pipe": True, "at": 40.0, "group_items": {"bad id": _A}},
{"name": "가드레일", "pipe": False, "at": 40.0, "summary_item": {"key": ""}},
# 이웃 측점 구조물 = 뺌
{"name": "옹벽", "pipe": False, "at": 40.6, "summary_item": _C},
]
_LINES = [
"기슭막이 메쌓기 1.2m 기초무",
"돌붙임 찰붙임",
"옹벽 콘크리트 H=2.0",
"돌쌓기(찰) 조합 미지정",
"가드레일 조합 미지정",
]
def test_구조물_여럿_글은_조합_이름_미지정_배관_본체_뺌_TS_파이썬_같음():
culvert = {**_culvert_set(_PIPE), "chainage_m": 40.2}
cases = [
_case(_THINGS, 40.0, culvert, {"pipe_length_m": 8.5}),
_case(_THINGS, 40.0, None, {}),
_case(_THINGS[5:], 40.0, None, {}),
]
mirrored = _helper("mirror", cases)
for case, ts in zip(cases, mirrored, strict=True):
section = case["section"]
lines = structure_note_lines(case["things"], 40.0)
py = note_entities(40.0, section["culvert"], section["design"], "NO.2", lines)
assert ts["lines"] == lines
assert _strip(ts["entities"]) == _strip(py)
full, bare, none = mirrored
assert full["lines"] == _LINES
# 배관 = 원형 마크(원 안 배수관 · Φ · L 정수) · 오른쪽 측점 + 조합 줄
assert [e["type"] for e in full["entities"][:2]] == ["Circle", "Line"]
assert _labels(full["entities"]) == ["배수관", "Φ800m/m", "L=9m", "NO.2", *_LINES]
assert "흄관 1200" not in _labels(full["entities"])
# 원형 마크 없음(관 없는 구조물 측점) = 측점 + 줄 · 구조물 없는 측점 = 빈 도면
assert _labels(bare["entities"]) == ["NO.2", *_LINES]
assert none["entities"] == [] and none["at"] == 0
def test_글_줄_재료는_관_지점_구조물_배치_종류_이름():
payload = {
"pipes": [
{"chainage_m": 40.2, "facility": "pipe", "group_items": {"gnbf9m9": _A}},
{"chainage_m": 60.0, "facility": "box_culvert"},
],
"structures": [
{"type_id": "retaining_wall", "chainage_m": 40.0, "summary_item": _C},
{"type_id": "guardrail", "start_m": 30.0, "end_m": 80.0},
{"type_id": "mystery", "chainage_m": None, "start_m": None},
],
"types": [
{"type_id": "pipe", "name": "배관"},
{"type_id": "box_culvert", "name": "BOX암거"},
{"type_id": "retaining_wall", "name": "옹벽"},
{"type_id": "guardrail", "name": "가드레일"},
],
}
things = _helper("load", payload)
assert [(t["name"], t["pipe"], t["at"]) for t in things] == [
("배관", True, 40.2),
("BOX암거", False, 60.0),
("옹벽", False, 40.0),
("가드레일", False, 30.0), # 구간형 = 시작 측점(기준 측점 없을 때)
]
assert things[0]["group_items"] == {"gnbf9m9": _A} and things[2]["summary_item"] == _C
def test_파이썬_짝_재료는_정본_관_지점_구조물_배치(tmp_path):
path = pipe_points_file(tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
point = {"chainage_m": 40.2, "lonlat": [0, 0], "facility": "pipe", "options": _PIPE}
point["group_items"] = {"gnbf9m9": _A}
path.write_text(json.dumps({"points": [point]}, ensure_ascii=False), encoding="utf-8")
spath = Path(structures_file_path(str(tmp_path)))
spath.parent.mkdir(parents=True, exist_ok=True)
wall = {
"structure_id": "w1",
"type_id": "retaining_wall",
"placement": "point",
"chainage_m": 40.0,
"options": {},
"memo": "",
"placement_source": "manual",
"status": "draft",
"revision": 1,
"geometry": None,
"summary_item": _C,
}
doc = {"revision": 1, "structures": [wall]}
spath.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
sources = load_note_sources(tmp_path)
assert [(t["name"], t["at"]) for t in sources[1]] == [("배관", 40.2), ("옹벽", 40.0)]
seeded = with_structure_note({"pipe_length_m": 8.4}, 40.0, sources, "NO.2")
labels = _labels(seeded["structure_note_cad"]["entities"])
assert labels == ["배수관", "Φ800m/m", "L=8m", "NO.2", _A["name"], _C["name"]]
# ── 265-3 관 길이 정수 ─────────────────────────────────────────────────
@@ -53,14 +222,7 @@ def _labels(entities: list[dict]) -> list[str]:
def test_원형_마크_L_은_사사오입_정수_TS_파이썬_같음(length, label):
design = {} if length is None else {"pipe_length_m": length}
culvert = {**_culvert_set(_PIPE), "chainage_m": 40.0}
case = {
"section": {"chainage_m": 40.0, "culvert": culvert, "design": design},
"station": "NO.2",
"options": _PIPE,
"chainage": 40.0,
"interval": 20.0,
}
[ts] = _mirror([case])
py = culvert_note_entities(40.0, culvert, design, "NO.2", _PIPE)
[ts] = _helper("mirror", [_case([], 40.0, culvert, design)])
py = note_entities(40.0, culvert, design, "NO.2", [])
assert _labels(ts["entities"])[2] == label
assert _labels(py)[2] == label
+124
View File
@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
"""269 좌측 패널 컨테이너 공용 양식 — 기준 = B05/B06 「구조물 배치」.
못박는 것
- 공용 양식 = ui_template 한 곳(`.ui-panel-form` · `.ui-panel-btn` · 카드 `createPanelCard`) ·
칸 높이 · 라벨 틈 토큰 = theme 한 곳 · 「구조물 배치」도 같은 토큰.
- B07 컨테이너 = 공용 카드 · 도면 단추 = `.ui-panel-btn` · 페이지 덧칠(크기 · 글자 · 바탕) 없음.
- B06 컨테이너(횡단 보기 설정 · 표준 횡단면 설정) = 칸 틀 `createSectionFrame` 이 공용 양식을 붙임 ·
단추 · 입력칸 = 공용 함수(createButton · createInputField · createSelectField) · B06 덧칠 없음.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
THEME = _read("ui_template/ui_template_theme.css")
OVERLAY = _read("ui_template/ui_template_overlay.css")
COLLAPSIBLE = _read("ui_template/ui_template_collapsible.ts")
B05_STRUCT_CSS = _read("B05_Profile/B05_Profile_UI_Style_Structures.css")
B07_CSS = _read("B07_DesignDetail/B07_DesignDetail_UI_Style.css")
B07_PANELS = _read("B07_DesignDetail/B07_DesignDetail_UI_Panels.ts")
B07_PLAN = _read("B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts")
DESIGN_PANEL = _read("A00_Common/design/design_panel.ts")
B06_CSS = _read("B06_Section/B06_Section_UI_Style.css")
B06_PANEL = _read("B06_Section/B06_Section_UI_Page_Panel.ts")
B06_STD = _read("B06_Section/B06_Section_UI_Standard_Panel.ts")
def _block(css: str, selector: str) -> str:
m = re.search(r"(?:^|\n)" + re.escape(selector) + r"\s*\{([^}]*)\}", css)
assert m, selector
return m.group(1)
def test_공용_양식_토큰은_theme_한_곳_구조물_배치도_같은_토큰():
assert "--panel-control-h: 26px;" in THEME
assert "--panel-label-gap: 2px;" in THEME
assert "--b05-control-h: var(--panel-control-h);" in B05_STRUCT_CSS
assert "--b05-label-gap: var(--panel-label-gap);" in B05_STRUCT_CSS
def test_공용_양식_칸_라벨_단추_규칙():
field = _block(
OVERLAY,
'.ui-panel-form input:not([type="checkbox"], [type="radio"], [type="range"]),\n'
".ui-panel-form select",
)
assert "height: var(--panel-control-h);" in field
assert "padding: 0 6px;" in field
assert "font-size: var(--text-caption);" in field
label = _block(OVERLAY, ".ui-panel-form .ui-field,\n.ui-panel-form .b05-route__field")
assert "gap: var(--panel-label-gap);" in label
assert "display: none;" in _block(OVERLAY, ".ui-panel-form .ui-field__error:not(.is-visible)")
button = _block(OVERLAY, ".ui-panel-form .ui-btn:not(.ui-help-btn)")
# 「구조물 배치」 단추(b05-route__irregular-btn)와 같은 크기
assert "padding: var(--spacing-8);" in button
assert "font-size: var(--text-caption);" in button
assert "border-radius: var(--radius-inputs);" in button
raw = _block(OVERLAY, ":where(.ui-panel-btn)")
for decl in (
"padding: var(--spacing-8);",
"border-radius: var(--radius-inputs);",
"background: var(--color-surface);",
"font-size: var(--text-caption);",
):
assert decl in raw, decl
# 아래 단추 묶음은 컨테이너 밖 — 공용 양식이 `.ui-sidebar-actions` 를 겨누지 않음
form_rules = OVERLAY[OVERLAY.index("좌측 패널 컨테이너 공용 양식(269)") :]
form_rules = form_rules[: form_rules.index("/* 카드 안에 다시 카드")]
assert "ui-sidebar-actions {" not in form_rules and "ui-sidebar-actions ." not in form_rules
def test_공용_카드_함수():
assert "export function createPanelCard(" in COLLAPSIBLE
assert "ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form" in COLLAPSIBLE
def test_B07_컨테이너_공용_카드_도면_단추_공용_꼴():
assert "createPanelCard(`${group.label} ${items.length}`)" in B07_PANELS
assert "createPanelCard(`평면도 ${items.length}`)" in B07_PLAN
for src in (B07_PANELS, B07_PLAN):
assert "ui-collapsible ui-sidebar-section" not in src # 손으로 짠 카드 없음
assert B07_PANELS.count('"b07-drawing-button ui-panel-btn"') == 2
assert '"ui-panel-btn b07-plan-manage-open"' in B07_PLAN
def test_B07_페이지_덧칠_없음():
assert not re.search(r"(?:^|\n)\.b07-drawing-group\s*\{", B07_CSS)
assert ".b07-drawing-group h3 {" not in B07_CSS
assert ".b07-plan-manage-open {" not in B07_CSS
button = _block(B07_CSS, ".b07-drawing-button")
for prop in ("min-height", "padding", "border-radius", "background", "font-size"):
assert not re.search(r"(?:^|\s)" + prop + r"\s*:", button), prop
assert "font-size" not in _block(B07_CSS, ".b07-drawing-button__name")
assert ".b07-cross-layout .ui-btn" not in B07_CSS
assert ".b07-cross-layout .ui-field__error" not in B07_CSS
def test_B06_컨테이너_공용_양식():
frame = DESIGN_PANEL[DESIGN_PANEL.index("export function createSectionFrame(") :]
frame = frame[: frame.index("\n}\n")]
cross = frame[frame.index("b06-profile__group ui-collapsible") :]
assert "ui-panel-form" in cross.splitlines()[0]
for src in (B06_PANEL, B06_STD):
assert "createSectionFrame(" in src or "createButton(" in src
assert "<button" not in src and 'createElement("button")' not in src
assert 'createElement("input")' not in src and 'createElement("select")' not in src
assert "createInputField(" in B06_PANEL and "createButton(" in B06_PANEL
# 덧칠 없음 — 칸 글자 · 오류 칸 접기는 공용 양식 몫
group = _block(B06_CSS, ".b06-profile__group")
assert "font-size" not in group and not re.search(r"(?:^|\s)color\s*:", group)
assert ".b06-profile__field-row .ui-field__error" not in B06_CSS
for sel in (".ui-btn {", ".ui-input {", ".ui-select {"):
for line in B06_CSS.splitlines():
if line.endswith(sel) and line.startswith(".b06-profile"):
rule = _block(B06_CSS, line[:-2])
for prop in ("height", "padding", "font-size"):
assert not re.search(r"(?:^|\s)" + prop + r"\s*:", rule), (line, prop)
+11
View File
@@ -11,6 +11,17 @@
* 스타일은 전역 `ui_template_theme.css`에 있어 어떤 페이지에서도 클래스만 붙이면 동작한다.
* ========================================================================== */
/** 좌측 패널 컨테이너 공용 카드(269) — 접는 제목 + 공용 양식(`ui-panel-form`) · 내용은 카드에 바로 붙임. */
export function createPanelCard(title: string, collapsed = false): HTMLElement {
const section = document.createElement("section");
section.className = `ui-collapsible ui-sidebar-section ui-sidebar-card ui-panel-form${collapsed ? " is-collapsed" : ""}`;
const heading = document.createElement("h3");
heading.className = "ui-collapsible__title";
heading.textContent = title;
section.append(heading);
return section;
}
/** root 아래 `.ui-collapsible__title` 클릭을 위임 처리해 해당 컨테이너를 접거나 편다. */
export function attachCollapsible(root: HTMLElement): void {
root.addEventListener("click", (event) => {
+71
View File
@@ -415,6 +415,77 @@
font-size: var(--text-body-sm);
}
/* 좌측 패널 컨테이너 공용 양식(269) — 기준 = B05/B06 「구조물 배치」(칸 높이 · 라벨 · 글자 · 단추).
B05~B09 좌측 컨테이너가 `.ui-panel-form`(카드는 `createPanelCard`)을 붙임 · 아래 단추 묶음
(`.ui-sidebar-actions`)은 컨테이너 밖이라 안 먹음 · 날 단추는 `.ui-panel-btn`. */
:where(.ui-sidebar-card.ui-panel-form) {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.ui-panel-form .ui-field,
.ui-panel-form .b05-route__field {
gap: var(--panel-label-gap);
}
.ui-panel-form .ui-field__label,
.ui-panel-form .b05-route__field > span {
font-size: var(--text-caption);
line-height: 1.2;
}
.ui-panel-form .ui-field__error:not(.is-visible) {
display: none;
}
.ui-panel-form input:not([type="checkbox"], [type="radio"], [type="range"]),
.ui-panel-form select {
box-sizing: border-box;
height: var(--panel-control-h);
padding: 0 6px;
font-size: var(--text-caption);
line-height: normal;
}
.ui-panel-form select.ui-select {
padding-right: 24px;
background-position: right 6px center;
background-size: 12px;
}
/* 숫자 칸 증감 화살표 없음 — 「구조물 배치」와 같게 */
.ui-panel-form input[type="number"] {
appearance: textfield;
-moz-appearance: textfield;
}
.ui-panel-form input[type="number"]::-webkit-outer-spin-button,
.ui-panel-form input[type="number"]::-webkit-inner-spin-button {
margin: 0;
appearance: none;
-webkit-appearance: none;
}
/* 단추 크기 = 「구조물 배치」 단추(`b05-route__irregular-btn`) · 색은 단추 갈래(filled · ghost) 그대로 */
.ui-panel-form .ui-btn:not(.ui-help-btn) {
padding: var(--spacing-8);
border-radius: var(--radius-inputs);
font-size: var(--text-caption);
line-height: normal;
}
/* 날 <button>(도면 단추 등) — 「구조물 배치」 단추 꼴 · 낮은 우선순위라 페이지 상태 표시(띠 · 고름)가 덮음 */
:where(.ui-panel-btn) {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
cursor: pointer;
}
/* 카드 안에 다시 카드(M02 도면 안 구조물 도면 묶음) — 앞 목록과 간격 */
.ui-sidebar-card .ui-sidebar-card {
margin-top: var(--spacing-8);
+3
View File
@@ -161,6 +161,9 @@
/* 입력칸 · 고르기칸 높이 — 크기마다 한 값(같은 줄이면 위아래가 맞음) */
--control-height: 42px;
--control-height-sm: 30px;
/* 좌측 패널 컨테이너 칸 높이 · 라벨 틈 — 기준 = B05/B06 「구조물 배치」 · 공용 양식 `.ui-panel-form` 이 씀 */
--panel-control-h: 26px;
--panel-label-gap: 2px;
/* 줄 위 작은 단추 한 규격 — 종단 머리 줄 [초기화]~▼ · [배수유역도] · [새 창] 이 함께 씀(높이 = 글자 12 + 여백 2 + 테두리 2) */
--btn-bar-padding: 1px var(--spacing-8);