Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1

This commit is contained in:
2026-10-06 12:38:28 +09:00
33 changed files with 1864 additions and 103 deletions
-7
View File
@@ -1,7 +0,0 @@
/* 좌측 패널 아래 고정 영역의 단추 넷([초기화][저장][확정][보기 전환]) — 좁은 패널에서도 글자가
두 줄로 꺾이지 않게 안쪽 여백을 줄이고 줄바꿈을 막는다(계획서 24-2b). */
.b05-route__dock .b05-route__actions .ui-btn,
.b05-route__dock .b06-profile__actions .ui-btn {
padding-inline: var(--spacing-8);
white-space: nowrap;
}
+49 -9
View File
@@ -59,6 +59,14 @@ export interface SpecFormHooks {
* [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) ·
* undefined = 손 [추가]. */
count?: (c: SpecContainer) => number | null | undefined;
/** 그림이 쓰는 값(54-13) — 고르기 칸이 비었거나 자동 규칙일 때 그 값 · 꼬리(「기본」 · 「자동」) ·
* null = 없음. 보여 주기만(값 · 저장은 사용자가 고를 때만). `values` = 보이는 값(위 칸 반영). */
shown?: (
c: SpecContainer,
item: SpecItem,
values: Record<string, string>,
index: number,
) => { value: string; tag: string } | null;
}
export interface SpecForm {
@@ -105,18 +113,27 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return slot;
};
const control = (c: SpecContainer, item: SpecItem, values: Instance): HTMLElement => {
const control = (
c: SpecContainer,
item: SpecItem,
values: Instance,
tag?: string,
): HTMLElement => {
if (item.kind === "select") {
const select = document.createElement("select");
const choices = choicesOf(item, values);
const now = values[item.id] ?? "";
const drawn = choices.find((ch) => ch.value === now);
// 그림이 쓰는 값은 빈 보기 자리에 꼬리를 달아 보임 — 고른 값과 구별(54-13)
const name = drawn ? choiceLabel(c, item, drawn, names) : now;
const blank = !tag ? "— 안 정함 —" : name ? `${name} (${tag})` : tag;
select.replaceChildren(
new Option("— 안 정함 —", ""),
new Option(blank, ""),
...choices.map((ch) => new Option(choiceLabel(c, item, ch, names), ch.value)),
);
const now = values[item.id] ?? "";
// 선택지 밖 옛 값은 그 값만 남겨 보임(지운 값을 몰래 바꾸지 않음)
if (now && !choices.some((ch) => ch.value === now)) select.add(new Option(now, now));
select.value = now;
if (!tag && now && !drawn) select.add(new Option(now, now));
select.value = tag ? "" : now;
return select;
}
if (item.kind === "text") {
@@ -139,7 +156,19 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return stepper(input, item.step ?? 0.1);
};
const onEdit = (c: SpecContainer, item: SpecItem, values: Instance, raw: string): void => {
const onEdit = (
c: SpecContainer,
item: SpecItem,
values: Instance,
raw: string,
view: Instance,
): void => {
// 그림 값을 보이던 위 칸 아래를 고침 = 그 위 값을 고른 것 — 다른 갈래 값은 비움(54-13)
const parent = item.by?.item;
if (parent && view[parent] !== values[parent]) {
values[parent] = view[parent];
for (const child of c.items) if (child.by?.item === parent) values[child.id] = "";
}
values[item.id] = raw;
if (item.split) {
const order = [item.split, ...(recent.get(values) ?? []).filter((r) => r !== item.split)];
@@ -170,15 +199,26 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
const title = containerTitle(c, names, groupNames[c.link]);
const box = group(c.required ? title : `${title} ${index + 1}`);
box.root.dataset.specContainer = c.id;
// 보이는 값 = 고른 값 + 그림이 쓰는 값(54-13) · 위 칸이 다른 갈래를 보이면 아래 칸은 빈 채로
const view: Instance = { ...values };
const tags = new Map<string, string>();
for (const item of c.items) {
if (item.by && view[item.by.item] !== values[item.by.item]) view[item.id] = "";
const drawn = item.kind === "select" ? hooks.shown?.(c, item, view, index) : null;
if (!drawn) continue;
view[item.id] = drawn.value;
tags.set(item.id, drawn.tag);
}
const fields = c.items
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, values))
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, view))
.map((item) => {
const input = control(c, item, values);
const input = control(c, item, view, tags.get(item.id));
const wrap = labeled(itemLabel(c, item, names), input);
wrap.dataset.specItem = `${c.id}.${item.id}`;
if (tags.has(item.id)) wrap.dataset.specShown = tags.get(item.id);
const target = input.querySelector?.("input, select") ?? input;
target.addEventListener("change", () =>
onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value),
onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value, view),
);
return wrap;
});
@@ -25,6 +25,13 @@ export function setPipeExtraCountSource(source: (section: CrossSection) => numbe
extraCountSource = source;
}
/** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */
type InletChoice = NonNullable<CrossSection["design"]>["inlet_structure"];
let inletSource: ((section: CrossSection) => InletChoice) | null = null;
export function setPipeInletSource(source: (section: CrossSection) => InletChoice): void {
inletSource = source;
}
/** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */
function ownerSection(
chainageM: number | null,
@@ -64,6 +71,34 @@ export function pipeExtraCount(chainageM: number | null): number | null {
return computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet;
}
/** 그림이 쓰는 유입 · 유출 값(54-13) — 집수정 모양(없으면 null = 흙막이 벽) · 벽 형태(유입 · 유출 ·
* 흙막이 n). 폼 빈 · 자동 칸에 보여 주기만. 횡단 자료가 없으면 null(모름). */
export function pipeDrawn(chainageM: number | null): {
basin: string | null;
forms: Record<string, string | null>;
extras: Array<string | null>;
} | null {
const found = ownerSection(chainageM);
const design = found?.owner.design;
if (!found || !design) return null;
const { owner, sections } = found;
const outlet = extraCountSource?.(owner) ?? design.extra_wall_counts?.outlet ?? 0;
const counts = { basin: 0, ...design.extra_wall_counts, outlet };
const inlet = inletSource?.(owner) ?? design.inlet_structure;
const probe = {
...owner,
design: { ...design, extra_wall_counts: counts, inlet_structure: inlet },
};
const layout = computeStoredLayouts(probe, sections)?.culvert;
if (!layout) return null;
const formOf = (role: string) => layout.walls.find((w) => w.role === role)?.form ?? null;
return {
basin: layout.basin?.shape ?? null,
forms: { inlet: formOf("inlet"), outlet: formOf("outlet") },
extras: layout.extraWalls.map((w) => w.form),
};
}
/** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */
export function guardHeightHint(diameterMm: string): string {
const d = Number(diameterMm || "1000") / 1000;
+4
View File
@@ -76,6 +76,7 @@ import type { DesignPageHost } from "./design_page";
import {
pipeExtraCount,
setPipeExtraCountSource,
setPipeInletSource,
setPipeSectionsSource,
} from "./design_structure_pipe_calc";
import {
@@ -296,6 +297,8 @@ export async function mountCrossView(
});
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
// 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선
setPipeInletSource((section) => stationControls.inletStructure.valueFor(section));
const sectionView = createSectionView(
(chainageM, change) => {
@@ -576,6 +579,7 @@ export async function mountCrossView(
}
// 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다.
sectionDetail = await loadSectionDetail(projectId, context.route_id);
structuresPanel.facility.recount(); // 단 수는 횡단 자료로 셈 — 흙막이 상자 · 목록 램프 다시(54-15)
// 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면
// 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측).
if (sectionDetail) {
@@ -20,6 +20,7 @@ from B05_Profile.B05_Profile_Engine_Solver import (
_cost_surface_signature,
_load_or_build_cost_surface,
)
from common_util.common_util_surface_sampler import check_surface_keys
from config.config_system import (
SKELETON_MAIN_RIDGE_ACC_THRESHOLD_CELLS,
SKELETON_MAIN_VALLEY_ACC_THRESHOLD_CELLS,
@@ -277,6 +278,7 @@ def load_or_build_skeleton(
project_root: Path, filter_key: str, method: str, smooth: bool
) -> dict[str, Any]:
"""스켈레톤을 캐시에서 로드하거나 새로 계산해 저장한다."""
check_surface_keys(filter_key, method)
project_root = Path(project_root)
models_dir = project_root / _MODELS_SUBDIR
cache_dir = project_root / _ROUTE_CACHE_SUBDIR
+5
View File
@@ -19,6 +19,7 @@ from B05_Profile.B05_Profile_Engine_Geometry import (
resample_polyline_2d,
single_segment_dijkstra,
)
from common_util.common_util_surface_sampler import check_surface_keys
from config.config_system import (
FOREST_ROAD_MAX_GRADE,
FOREST_ROAD_MIN_CURVE_R_M,
@@ -45,6 +46,7 @@ _ROUTE_CACHE_SUBDIR = Path("B05_Profile") / "route"
def _load_dtm_grid(models_dir: Path, filter_key: str, smooth: bool):
"""필터의 정규 DTM 격자(x, y, z, valid_mask)를 로드한다."""
check_surface_keys(filter_key)
suffix = "_smooth" if smooth else ""
dtm_path = models_dir / f"dtm_{filter_key}{suffix}.npz"
if not dtm_path.exists():
@@ -70,6 +72,7 @@ def _sample_surface_on_grid(
dtm_z: np.ndarray,
) -> np.ndarray:
"""확정 지표면 모델의 표고를 DTM 격자에 샘플링한다(실패 시 DTM으로 폴백)."""
check_surface_keys(filter_key, method)
if method == "dtm":
return dtm_z
@@ -145,6 +148,7 @@ def _sample_surface_on_grid(
def _source_npz_paths(models_dir: Path, filter_key: str, method: str, smooth: bool) -> list[Path]:
"""비용면이 의존하는 소스 모델 파일(존재하는 것만, 안정 순서)."""
check_surface_keys(filter_key, method)
suffix = "_smooth" if smooth else ""
candidates = [
models_dir / f"dtm_{filter_key}{suffix}.npz",
@@ -211,6 +215,7 @@ def _load_or_build_cost_surface(
project_root: Path, models_dir: Path, filter_key: str, method: str, smooth: bool
):
"""비용면을 반환한다(서명 일치 시 캐시 재사용, 아니면 재빌드·재캐시)."""
check_surface_keys(filter_key, method)
cache_dir = project_root / _ROUTE_CACHE_SUBDIR
suffix = "_smooth" if smooth else ""
cache_path = cache_dir / f"cost_surface_{filter_key}_{method}{suffix}.npz"
+7 -4
View File
@@ -4,6 +4,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod
from config.config_system import (
GRADE_MAIN_DIRECTIONS,
GRADE_TERRAIN_TYPES,
@@ -63,8 +64,10 @@ class RouteSolveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
filter_key: str = Field(description="지면 필터 키 (grid_min_z/csf/pmf)")
method: str = Field(default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)")
filter_key: SurfaceFilter = Field(description="지면 필터 키 (grid_min_z/csf/pmf)")
method: SurfaceMethod = Field(
default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)"
)
smooth: bool = Field(default=False)
surface_model_id: int | None = Field(default=None, description="기반 지표면 모델 id")
algorithm: str = Field(
@@ -269,8 +272,8 @@ class RouteConfirmRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
filter_key: str | None = None
method: str | None = None
filter_key: SurfaceFilter | None = None
method: SurfaceMethod | None = None
smooth: bool = False
surface_model_id: int | None = None
irregular_stations: list[IrregularStationInput] = Field(default_factory=list)
+5 -1
View File
@@ -164,6 +164,10 @@ def registry_schema_version() -> int:
return int(_read_definition(*_stamp()).get("schema_version", 1))
#: 구조물 id 모양 — 복사본 폴더 이름(`M02_Item_Snapshot.FOLDER_PATTERN`)이 되므로 경로 글자 금지
STRUCTURE_ID_RULE = r"[0-9A-Za-z][0-9A-Za-z_-]*"
class StructureInstance(BaseModel):
"""배치된 구조물 1건.
@@ -173,7 +177,7 @@ class StructureInstance(BaseModel):
model_config = ConfigDict(extra="forbid")
structure_id: str | None = None
structure_id: str | None = Field(default=None, pattern=rf"^{STRUCTURE_ID_RULE}$", max_length=64)
type_id: str
placement: Placement
chainage_m: float | None = Field(default=None, ge=0)
@@ -46,6 +46,7 @@ import {
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form";
import { branchOf } from "../A00_Common/design/design_structure_spec";
import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe";
import {
BOX_SPEC,
@@ -57,6 +58,7 @@ import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structu
import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
import {
guardHeightHint,
pipeDrawn,
pipeExtraCount,
pipeLengthM,
} from "../A00_Common/design/design_structure_pipe_calc";
@@ -212,6 +214,8 @@ export interface FacilityOptionsForm {
definition?: {
spec_names?: Record<string, string>;
groups?: Array<{ id: string; name: string }>;
/** 옵션 정의 기본값 — 빈 고르기 칸이 그림 값을 보임(54-13). */
options?: Array<{ key: string; default: unknown }>;
} | null,
) => void;
/** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */
@@ -267,7 +271,52 @@ export function createFacilityOptionsForm(
clear: (key) => linkSource?.clear(key),
set: (key, item) => linkSource?.set(key, item),
};
// 그림이 쓰는 값(54-13) — 빈 고르기 칸 = 정의 기본값(엔진 · 횡단도가 그 값으로 그림) · 보여 주기만
let defaults: Record<string, unknown> = {};
const byDefault = (key: string | undefined): { value: string; tag: string } | null => {
const value = key ? defaults[key] : null;
return value === null || value === undefined || value === ""
? null
: { value: String(value), tag: "기본" };
};
/** 빈 날개벽 설치 = 정의 기본 설치(옛 「있음」 · 빈 값 = 날개벽 · 엔진 `_wing_spec`). */
const wingDefault = (key: string): { value: string; tag: string } => {
const value = String(defaults[key] ?? "");
return { value: ["없음", "흙막이"].includes(value) ? value : "날개벽", tag: "기본" };
};
// 유입 「자동(규칙)」 = 기슭막이 + 유입 형식 auto(옛 값 · 정의 기본) — 그림은 지형 규칙이 집수정 · 흙막이를
// 정함. 사용자가 유입 구조를 고를 때까지 저장도 그대로(흙막이 고름의 형식 revet 을 안 적음)
let inletAuto: { structure?: string | number } | null = null;
const inletIsAuto = (): boolean =>
!!inletAuto && pipeForm.state().inlet?.[0]?.structure === "기슭막이";
// 그림 계산은 한 번 그릴 동안 한 번만(칸마다 부름)
let drawnMemo: ReturnType<typeof pipeDrawn> | undefined;
const drawnNow = (): ReturnType<typeof pipeDrawn> => {
if (drawnMemo === undefined) {
drawnMemo = pipeDrawn(chainage);
queueMicrotask(() => (drawnMemo = undefined));
}
return drawnMemo;
};
const pipeForm = createSpecForm(specLinks, {
shown: (c, item, values, index) => {
if (c.id === "inlet" && item.id === "structure" && (!values.structure || inletIsAuto())) {
const drawn = drawnNow();
return drawn
? { value: drawn.basin ? "집수정" : "기슭막이", tag: "자동" }
: { value: "", tag: "자동(지형 규칙)" };
}
if (values[item.id]) return null;
if (item.id !== "form") return byDefault(branchOf(item, values)?.key);
const basin = c.id === "inlet" && values.structure === "집수정";
const drawn = drawnNow();
const form = basin
? drawn?.basin
: c.id === "soil_add"
? drawn?.extras[index]
: drawn?.forms[c.id];
return form ? { value: form, tag: basin ? "자동" : "기본" } : null;
},
auto: (_c, item) => {
if (item.id !== "length") return "";
const length = pipeLengthM(chainage);
@@ -276,13 +325,21 @@ export function createFacilityOptionsForm(
// 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1)
count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined),
hint: (c, item, state) => {
const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이";
const soil =
c.id !== "inlet" ||
(state.inlet?.[0]?.structure === "기슭막이" && !(inletIsAuto() && drawnNow()?.basin));
return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : "";
},
onChange: (c, item) => {
if (c.id === "inlet" && item.id === "structure") inletAuto = null;
if (c.id === "inlet" && (item.id === "structure" || item.id === "form"))
inletStructureHandler?.(inletStructureOf());
emit();
// 고른 뒤 그림이 바뀜 — 자동 표시를 새 그림으로 다시 그림(54-13)
if (c.id === "inlet") {
drawnMemo = undefined;
pipeForm.refresh();
}
},
});
// 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4)
@@ -303,6 +360,11 @@ export function createFacilityOptionsForm(
const fordNeed = (): number | null =>
fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? ""));
const fordForm = createSpecForm(specLinks, {
shown: (c, item, values) => {
if (values[item.id]) return null;
if (c.id !== "ford") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out");
return item.id === "diameter" ? byDefault("pipe_diameter_mm") : null;
},
hint: (c, item, state) =>
c.id === "ford" && item.id === "height"
? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? ""))
@@ -368,7 +430,18 @@ export function createFacilityOptionsForm(
};
// ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ──
const boxForm = createSpecForm(specLinks, { onChange: () => emit() });
const boxForm = createSpecForm(specLinks, {
onChange: () => emit(),
shown: (c, item, values) => {
if (values[item.id]) return null;
if (c.id !== "body") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out");
const size = bodyOf({
body_width_m: Number(defaults.body_width_m ?? 2),
body_height_m: Number(defaults.body_height_m ?? 2),
}).size;
return item.id === "size" && size ? { value: size, tag: "기본" } : null;
},
});
// 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음
const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS);
const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS);
@@ -460,8 +533,10 @@ export function createFacilityOptionsForm(
inletStructure: inletStructureOf,
setInletStructure(value) {
// 조정창 형식 → 유입 보호공 구조 · 형태(자동 = 고른 구조는 지키고 형태만 비움)
if (value === "revet") pipeForm.setValue("inlet", "structure", "기슭막이");
else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정");
if (value === "revet") {
inletAuto = null;
pipeForm.setValue("inlet", "structure", "기슭막이");
} else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정");
if (value !== "revet" && pipeForm.state().inlet?.[0]?.structure === "집수정")
pipeForm.setValue("inlet", "form", value === "auto" ? "" : value);
syncVisibility();
@@ -505,9 +580,16 @@ export function createFacilityOptionsForm(
// 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7).
const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name]));
const names = definition?.spec_names ?? {};
defaults = Object.fromEntries((definition?.options ?? []).map((o) => [o.key, o.default]));
if (facility === "pipe") {
// 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1)
const { soil_add: _old, ...pipeNames } = names;
const kind = options.inlet_structure;
inletAuto =
options.inlet_type === "기슭막이" &&
!["revet", "I", "L", "U"].includes(String(kind ?? ""))
? { structure: kind }
: null;
pipeForm.write(PIPE_SPEC, options, pipeNames, groups);
}
if (facility === "box_culvert") {
@@ -537,6 +619,11 @@ export function createFacilityOptionsForm(
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
if (current === "pipe") {
Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1)
// 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13)
if (inletIsAuto()) {
if (inletAuto!.structure === undefined) delete options.inlet_structure;
else options.inlet_structure = inletAuto!.structure;
}
} else if (current === "box_culvert") {
// 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2)
Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0]));
+23 -13
View File
@@ -33,19 +33,29 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
(["iso", "top", "front", "side"] as const).forEach((preset) =>
viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")),
);
// 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을
// 가리킨다(누르면 그쪽으로 간다).
let projection: "perspective" | "ortho" = "perspective";
const projectionButton = button(
"직교로",
() => {
projection = projection === "perspective" ? "ortho" : "perspective";
projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로";
callbacks.onProjection(projection);
},
"glass",
);
viewButtons.append(projectionButton);
// 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음).
const projectionButtons = (
[
["perspective", "원근"],
["ortho", "직교"],
] as const
).map(([kind, label]) => {
const element = button(
label,
() => {
projectionButtons.forEach(({ kind: other, element: el }) => {
el.classList.toggle("is-active", other === kind);
el.setAttribute("aria-pressed", String(other === kind));
});
callbacks.onProjection(kind);
},
"glass",
);
element.classList.toggle("is-active", kind === "ortho");
element.setAttribute("aria-pressed", String(kind === "ortho"));
return { kind, element };
});
viewButtons.append(...projectionButtons.map(({ element }) => element));
const visibilityButtons = document.createElement("div");
visibilityButtons.className = "b05-route__view-group";
visibilityButtons.append(
@@ -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]),
);
}
@@ -65,7 +65,14 @@ export function optionControl(
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
if (option.input === "select") {
const blank = option.required ? "— 선택 —" : "— 안 정함 —";
// 빈 칸이 쓰는 정의 기본값은 빈 보기에 꼬리를 달아 보임 — 값은 안 고름(54-13)
const fallback = option.default === null || option.default === "" ? null : option.default;
const blank =
fallback !== null && fallback !== undefined
? `${label(String(fallback))} (기본)`
: option.required
? "— 선택 —"
: "— 안 정함 —";
// 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음)
const kept = value && !option.choices.includes(value) ? [value] : [];
const element = select([
+92 -2
View File
@@ -15,7 +15,17 @@ import {
} from "./B05_Profile_Api_Structures";
import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { readSummaryItem, summaryTooltip } from "../common_util/common_util_summary_item";
import {
readGroupItems,
readSummaryItem,
summaryLabel,
summaryTooltip,
type SummaryItem,
} from "../common_util/common_util_summary_item";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc";
import { containerTitle, linkOf, readState } from "../A00_Common/design/design_structure_spec";
import { asRoad } from "./B05_Profile_UI_Structures_Define";
export interface StructureListParams {
/** 목록을 담을 <ul>. 그릴 때마다 통째로 갈아 끼운다. */
@@ -62,6 +72,72 @@ export function etcName(structure: StructureInstance): string | null {
return name || null;
}
/** 잇기 자리 하나 — 상자 이름 · 이은 항목(없으면 null) · 필수 여부. */
interface LinkLamp {
title: string;
item: SummaryItem | null;
required: boolean;
}
/** 줄의 잇기 자리(54-15) — 폼이 잇기 줄을 세우는 규칙 그대로: 명세 컨테이너마다(선택은 인스턴스마다 ·
* 배관 흙막이는 지형이 세운 단까지) · 정의 그룹마다(옛 종류 단위 값 = 첫 그룹 · 54-6) · 그 밖은
* 종류 단위 하나 · 종단배수(B군)는 없음. */
export function linkLamps(
type: StructureType | undefined,
source: {
type_id: string;
options?: Record<string, string | number>;
summary_item?: unknown;
group_items?: unknown;
},
chainageM: number | null,
): LinkLamp[] {
const links = readGroupItems(source.group_items);
const summary = readSummaryItem(source.summary_item);
const spec = STRUCTURE_SPECS[source.type_id];
if (spec?.containers.length) {
const groupNames = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name]));
// 배관 흙막이 = 명세 이름 「흙막이」가 정의 옛 이름 「흙막이 추가」보다 앞섬(54-1 · 시설 폼과 같음)
const { soil_add: _old, ...pipeNames } = type?.spec_names ?? {};
const names = source.type_id === "pipe" ? pipeNames : (type?.spec_names ?? {});
const state = readState(spec, source.options ?? {});
return spec.containers.flatMap((c): LinkLamp[] => {
const title = containerTitle(c, names, groupNames[c.link]);
if (c.required) return [{ title, item: links[c.link] ?? null, required: true }];
const n = (c.id === "soil_add" ? pipeExtraCount(chainageM) : null) ?? state[c.id].length;
return Array.from({ length: n }, (_, i) => ({
title: `${title} ${i + 1}`,
item: links[linkOf(c, i + 1)] ?? null,
required: false,
}));
});
}
if (type?.groups?.length) {
const first = type.groups[0].id;
return type.groups.map((g) => ({
title: g.name,
item: links[g.id] ?? (g.id === first ? summary : null),
required: true,
}));
}
if (!type || type.group === "B") return [];
return [{ title: "구조물 조합", item: summary, required: true }];
}
/** 램프 칸 — 3행 × 열(위 → 아래 → 다음 열) · 색 = 이음 · 필수 미지정 · 선택 미지정 · 툴팁 = 상자 · 항목. */
function lampCell(lamps: LinkLamp[]): HTMLElement {
const cell = document.createElement("div");
cell.className = "b05-route__lamps";
for (const lamp of lamps) {
const dot = document.createElement("i");
dot.className = lamp.item ? "is-linked" : lamp.required ? "is-required" : "is-optional";
cell.append(dot);
}
if (lamps.length)
cell.title = lamps.map((lamp) => `${lamp.title} · ${summaryLabel(lamp.item)}`).join("\n");
return cell;
}
export function renderStructureList(params: StructureListParams): void {
const { list, typeMap, intervalM, editingId, selectedPipeChainage } = params;
list.replaceChildren();
@@ -101,6 +177,20 @@ export function renderStructureList(params: StructureListParams): void {
: (typeMap.get(row.pipe.facility)?.name ?? row.pipe.facility),
),
);
const lamps = rows.map((row) =>
row.kind === "structure"
? linkLamps(typeMap.get(asRoad(row.structure).type_id), asRoad(row.structure), null)
: linkLamps(
typeMap.get(row.pipe.facility),
{ ...row.pipe, type_id: row.pipe.facility },
row.pipe.chainage_m,
),
);
// 램프 칸 폭 = 목록에서 가장 많은 열 — 줄마다 글 시작 자리가 같게(54-15)
list.style.setProperty(
"--lamp-cols",
String(Math.max(0, ...lamps.map((l) => Math.ceil(l.length / 3)))),
);
rows.forEach((row, index) => {
const item = document.createElement("li");
item.className = "b05-route__irregular-item";
@@ -132,7 +222,7 @@ export function renderStructureList(params: StructureListParams): void {
// 사용자). 패널이 막 펼쳐진 참이라 자리가 잡힌 다음 프레임에 민다.
if (picked) requestAnimationFrame(() => item.scrollIntoView({ block: "nearest" }));
}
item.append(station, name);
item.append(lampCell(lamps[index]), station, name);
list.append(item);
});
}
+52 -36
View File
@@ -20,6 +20,7 @@ import {
type StructureType,
} from "./B05_Profile_Api_Structures";
import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List";
import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon";
import { stationLabel as formatStation } from "../common_util/common_util_station";
/** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로
@@ -40,6 +41,8 @@ const LANE_PADDING_PX = 2;
const OVERLAP_PX = 52;
/** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */
const ETC_PILL_CHARS = 3;
/** 라벨을 위로 비키는 높이(px) — 그래프 바닥 ▼ 단추(바닥 2 + 높이 17) + 틈 2(54-14). */
const EDIT_BUTTON_CLEARANCE_PX = 21;
/** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다).
* 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을
@@ -64,18 +67,8 @@ export interface StructureMarksOptions {
selectedId: string | null;
onSelect: (structureId: string | null) => void;
onMove: (structureId: string, toChainageM: number) => void;
}
function optionSummary(structure: StructureInstance, type: StructureType | undefined): string {
if (!type) return "";
return type.options
.map((option) => {
const value = structure.options?.[option.key];
if (value === undefined || value === "") return null;
return `${option.label} ${value}${option.unit ?? ""}`;
})
.filter(Boolean)
.join(" · ");
/** 라벨 선택 컨테이너 개수(폼과 같은 값 · 배관 흙막이 = 유출 단 수) — 없으면 저장된 것. */
containerCount?: (structure: StructureInstance) => ContainerCount | undefined;
}
/** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */
@@ -236,7 +229,17 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
if (selected)
lane.append(
buildBalloon(structure, type, anchorPx, row, options.stationIntervalM, displayName),
buildBalloon(
balloonLines(
structure,
type,
{ name: displayName, position: positionText(structure, options.stationIntervalM) },
options.containerCount?.(structure),
),
type,
anchorPx,
row,
),
);
});
@@ -259,44 +262,57 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
/** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다.
* 알약 **위**로 띄운다 — 아래로 펴면 도면 테이블에 가려 안 보인다(2026-08-18
* 사용자 지시). 2행 구조에서 1행(윗줄)은 좌측 위, 2행(아랫줄)은 우측 위로
* 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. */
* 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다.
* 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값(`balloonLines` · 54-14) · 그래프 바닥 ▼ 단추를 가리지
* 않게 단추 높이만큼 더 위 · 위가 잘리면 알약 아래로. */
function buildBalloon(
structure: StructureInstance,
lines: string[],
type: StructureType | undefined,
anchorPx: number,
row: number | null,
stationIntervalM: number,
/** 중복 번호가 붙은 표시 이름("옹벽 1") — 좌측 목록과 같은 표기(2026-08-19). */
displayName?: string,
): HTMLElement {
const balloon = document.createElement("div");
balloon.className = "b05-structure__balloon";
balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center");
balloon.style.left = `${anchorPx}px`;
balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`;
balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX}px`;
balloon.style.borderColor = type?.style?.color ?? "#888";
const [name, ...rest] = lines;
const title = document.createElement("strong");
title.textContent = displayName ?? type?.name ?? structure.type_id;
const position = document.createElement("span");
position.textContent = positionText(structure, stationIntervalM);
balloon.append(title, position);
const summary = optionSummary(structure, type);
if (summary) {
const detail = document.createElement("span");
detail.textContent = summary;
balloon.append(detail);
}
if (structure.memo) {
const memo = document.createElement("span");
memo.className = "b05-structure__balloon-memo";
memo.textContent = structure.memo;
balloon.append(memo);
}
title.textContent = name;
balloon.append(
title,
...rest.map((line) => {
const span = document.createElement("span");
span.textContent = line;
return span;
}),
);
requestAnimationFrame(() => flipIfClipped(balloon));
return balloon;
}
/** 라벨 위가 스크롤 상자 · 창 밖으로 잘리면 알약 아래로 편다(아래가 덜 잘릴 때만). */
function flipIfClipped(balloon: HTMLElement): void {
const lane = balloon.parentElement;
if (!balloon.isConnected || !lane) return;
let top = 0;
let bottom = window.innerHeight;
for (let at: HTMLElement | null = lane; at; at = at.parentElement)
if (getComputedStyle(at).overflowY !== "visible") {
const clip = at.getBoundingClientRect();
top = Math.max(top, clip.top);
bottom = Math.min(bottom, clip.bottom);
}
const box = balloon.getBoundingClientRect();
const hiddenAbove = top - box.top;
const hiddenBelow = lane.getBoundingClientRect().bottom + ROW_GAP_PX + box.height - bottom;
if (hiddenAbove <= 0 || hiddenBelow >= hiddenAbove) return;
balloon.style.bottom = "auto";
balloon.style.top = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`;
}
/** 마크를 좌우로 끌어 위치를 옮긴다. 구간형은 길이를 유지한 채 통째로 움직인다. */
function attachMarkDrag(
mark: HTMLElement,
@@ -540,7 +540,7 @@ export function createStructuresSection(
return {
root,
facility: facilityOptions,
facility: { ...facilityOptions, recount: () => (facilityOptions.recount(), renderList()) }, // 단 수 → 목록 램프(54-15)
listRoot: list,
hasSelection: () => editingId !== null || selectedPipeChainage !== null,
setTypes(next, pick = () => true) {
+35 -10
View File
@@ -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 {
@@ -462,7 +457,37 @@
/* 목록 줄 — 집계 항목이 지정된 구조물 · 관은 왼쪽 선 + 이름 색으로(밝음 · 어두움 같은 토큰). */
.b05-route__irregular-item.is-assigned {
border-left: 3px solid var(--color-success);
/* 테두리 두께 대신 안쪽 그림자 — 글 시작 자리가 다른 줄과 같게(54-15) */
border-left-color: var(--color-success);
box-shadow: inset 2px 0 0 var(--color-success);
}
/* 목록 줄 잇기 램프(54-15) — 측점 왼쪽 고정 폭 칸 · 3행 × 열(위 → 아래 → 다음 열) ·
폭 = 목록에서 가장 많은 열(`--lamp-cols`) · 줄 높이를 늘리지 않음 */
.b05-route__lamps {
display: grid;
flex: none;
grid-auto-flow: column;
grid-template-rows: repeat(3, 5px);
grid-auto-columns: 5px;
gap: 2px;
align-self: center;
width: max(0px, calc(var(--lamp-cols, 0) * 7px - 2px));
}
.b05-route__lamps i {
border-radius: 50%;
background: var(--color-success);
}
.b05-route__lamps i.is-required {
background: var(--color-text-muted);
}
.b05-route__lamps i.is-optional {
box-sizing: border-box;
border: 1px solid var(--color-text-muted);
background: transparent;
}
.b05-route__irregular-item.is-assigned span {
+6 -3
View File
@@ -289,8 +289,9 @@ export function createRouteViewer(): RouteViewer {
iso: [distance, distance, distance],
// 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다.
top: [0, distance, distance * TOP_VIEW_TILT],
front: [0, distance * 0.25, distance],
side: [distance, distance * 0.25, 0],
// 정면 = 남에서 북을, 측면 = 동에서 서를 수평으로 본다(장면 +z 가 남, +x 가 동).
front: [0, 0, distance],
side: [distance, 0, 0],
} as const;
const [x, y, z] = positions[view];
const camera = cameraRig.camera();
@@ -309,7 +310,9 @@ export function createRouteViewer(): RouteViewer {
if (!bounds) return;
const width = bounds.x[1] - bounds.x[0];
const depth = bounds.y[1] - bounds.y[0];
placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35, view);
// 수평 보기는 지형 앞 가장자리가 카메라에 가까워 원근에서 잘린다 — 그 반폭만큼 물린다.
const nearHalf = view === "front" ? depth / 2 : view === "side" ? width / 2 : 0;
placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35 + nearHalf, view);
}
async function reloadContours(interval: number): Promise<void> {
+2 -3
View File
@@ -2,8 +2,7 @@
* B05_Profile_UI_Viewer_Camera.ts
* B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기.
*
* 기본은 원근(시야각 45°) — B04 지표면 화면과 같은 조작감이다(2026-09-04 사용자 확정).
* 탑뷰에서 크기를 정밀하게 대조할 때만 직교로 바꾼다. 갈아 끼울 때 위치·시선·근평면·
* 기본은 직교 — 원근(시야각 45°)은 단추로 고른다. 갈아 끼울 때 위치·시선·근평면·
* 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다.
*
* 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것.
@@ -22,7 +21,7 @@ export function createCameraRig() {
const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000);
perspective.position.set(100, 120, 100);
const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000);
let kind: ProjectionKind = "perspective";
let kind: ProjectionKind = "ortho";
let aspect = 1;
let halfHeight = 100;
+17 -4
View File
@@ -26,6 +26,7 @@ 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 STRUCTURE_ID_RULE
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
from common_util.common_util_json import atomic_write_json
from common_util.common_util_summary_item import GROUP_JOIN, snapshot_folder_name
@@ -51,7 +52,7 @@ LOCAL_FILES = (VALUES_FILE, PICKS_FILE)
#: 복사본 폴더 이름 — 구조물 id(영문 · 숫자 · `_` `-`) 또는 `pipe@측점` · 옵션 그룹(PLAN 46)은
#: 뒤에 `__{그룹 id}`
FOLDER_PATTERN = re.compile(
r"^(?:pipe@\d+(?:\.\d+)?(?:__g[0-9a-z]{1,16})?|[0-9A-Za-z][0-9A-Za-z_-]*)$"
rf"^(?:pipe@\d+(?:\.\d+)?(?:__g[0-9a-z]{{1,16}})?|{STRUCTURE_ID_RULE})$"
)
@@ -69,6 +70,14 @@ def folder_name(owner_id: str) -> str:
return owner_id.replace("#", "n")
def safe_folder(base: Path, name: str) -> Path | None:
"""`base` 바로 아래 복사본 폴더 — 이름이 규칙 밖 · `base` 밖이면 None(지우기 · 쓰기 막음)."""
target = base / name
if not FOLDER_PATTERN.match(name) or target.resolve().parent != base.resolve():
return None
return target
def now_text() -> str:
return datetime.now().isoformat(timespec="seconds")
@@ -181,8 +190,8 @@ def migrate_items(project_root: str | Path) -> list[str]:
continue
users = [o for o, item in wanted.items() if snapshot_folder_name(item) == child.name]
for owner in users:
target = base / folder_name(owner)
if (target / MANIFEST_NAME).is_file():
target = safe_folder(base, folder_name(owner))
if target is None or (target / MANIFEST_NAME).is_file():
continue # 이미 새 복사본이 있음
shutil.copytree(child, target, dirs_exist_ok=True)
origin = manifest.get("원본") or {}
@@ -212,7 +221,11 @@ def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]:
failed: list[str] = []
removed: list[str] = []
for folder, (owner, item) in sorted(wanted.items()):
target = base / folder
target = safe_folder(base, folder)
if target is None:
logger.warning("복사본 폴더 이름이 규칙 밖 — 건너뜀: %r", folder)
failed.append(folder)
continue
manifest = read_json(target / MANIFEST_NAME)
if isinstance(manifest, dict) and same_item(manifest, item):
continue
+12 -1
View File
@@ -13,11 +13,21 @@ common_util로 옮겼다 — 두 화면의 종단 Z가 갈라지면 세부유역
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from typing import Literal, Protocol, get_args
import numpy as np
from scipy.interpolate import RegularGridInterpolator
#: 지면 필터 · 지표면 표현 허용 목록 — 파일 이름에 들어감(B04 `_FILTERS` · `MODEL_METHODS`)
SurfaceFilter = Literal["classification", "grid_min_z", "csf", "pmf", "ransac"]
SurfaceMethod = Literal["dtm", "tin", "nurbs", "implicit", "meshfree"]
def check_surface_keys(filter_key: str, method: str = "dtm") -> None:
"""파일 이름 만들기 전 검사 — 허용 목록 밖이면 ValueError(경로 넘기 막음)."""
if filter_key not in get_args(SurfaceFilter) or method not in get_args(SurfaceMethod):
raise ValueError(f"지면 필터 · 표현 이름이 허용 목록 밖: {filter_key!r} · {method!r}")
class SurfaceElevationSampler(Protocol):
"""종·횡단 생성기가 의존하는 최소 표고 조회 인터페이스."""
@@ -129,6 +139,7 @@ def build_surface_sampler(
models_dir: Path | str, source_filter: str, method: str, smooth: bool
) -> SurfaceElevationSampler:
"""1단계 확정 모델을 종·횡단용 일괄 XY 표고 sampler로 연다."""
check_surface_keys(source_filter, method)
models_dir = Path(models_dir)
smooth_suffix = "_smooth" if smooth and method in {"dtm", "tin"} else ""
dtm_smooth = models_dir / f"dtm_{source_filter}_smooth.npz"
@@ -0,0 +1,22 @@
# 구조물 표 09 — 사면안정 › 흙막이 · 옹벽 · 소단 (사용자 10-6 · 원문 그대로)
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목
(필수)컨테이너 제목 : 흙막이
사면안정 흙막이 측점 No, + 측점거리 형태 돌 쌓기(찰), 돌 쌓기(메), 콘크리트, 돌망태, 통나무/목재틀, 바자
길이 수기입력
높이 수기입력 및 횡단도에서 제어
기준측점 전 수기입력(길이 + 기준측점 전/후에 따라 연동 계산)
기준측점 후 수기입력(길이 + 기준측점 전/후에 따라 연동 계산)
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목
(필수)컨테이너 제목 : 옹벽
사면안정 옹벽 측점 No, + 측점거리 형태 콘크리트
길이 수기입력
높이 수기입력 및 횡단도에서 제어
기준측점 전 수기입력(길이 + 기준측점 전/후에 따라 연동 계산)
기준측점 후 수기입력(길이 + 기준측점 전/후에 따라 연동 계산)
구조물군 상세 종류 기본 옵션 컨테이너 1 컨테이너 1 옵셩 항목
(필수)컨테이너 제목 : 상세 내용
사면안정 소단 측점 No, + 측점거리 소단 폭 수기입력
소단 높이 수기입력
@@ -0,0 +1,91 @@
# 54-16 B05 · B06 자료 한 곳 확인 (조사 · 보고만)
- 기준 커밋 `2104cc359` · 검증 프로젝트 `sub8_검증`(`c81b21f8…`) · 노선 224
- 측정: 같은 창 안 넘김은 ORCA 탭 + `orca eval`. 도중 ORCA 탭이 통째로 새로 만들어져 세션 초안이 사라짐(앱 흠 아님) → 저장 · 새로고침 시나리오는 측정 전용 헤드리스 Chrome(같은 프로브 · 화면 안 띄움). ORCA 탭은 닫음
- 값 되돌림: 높이 2 · 관경 1000 · 60m 계획고 865.48 · 3+0 측구 일반 으로 저장해 되돌림 확인. 단 횡단 저장 때 서버가 면적을 다시 계산해 누가토량은 −13,417.5 → −12,843.4 로 바뀜(서버 정본 재계산 결과 · 되돌릴 값 아님)
## ① 자료마다 쥔 곳
세션 키 꼴 `aislo:{bucket}:{name}:{project}[:{route}]` (`A00_Common/b_page_state.ts:229-238`). 설계 가게(`design_store`)는 따로 값을 들지 않고 이 키를 바로 읽고 씀(`A00_Common/design/design_store.ts:133-153`) · 구독자 0.
| 자료 | 기준 자리 | 같이 쥔 곳(두 벌) | 쓰는 곳 | 읽는 곳 |
|---|---|---|---|---|
| 구조물 목록 | ss `draft:structures` | 종단 다리 `ownStructures` · 횡단 패널 `structures` · 한 벌 폼 `structures` · 한 벌 종단도 `structures` · 서버 `structures.json`(보일 때마다 다시 받음) · 가게 칸 `structures`(아무도 안 씀) | `B05_Profile/B05_Profile_Api_Structures.ts:305` ← `B05_Profile_UI_Page_Structures.ts:245` · `B06_Section/B06_Section_UI_Page_Structures_Panel.ts:177` | `B05_Profile_UI_Page_Structures.ts:331` · `B06_..._Structures_Panel.ts:345` · `B06_Section_Section_Store.ts:61` |
| 구조물 옵션 | 구조물 초안 안 `options` | 명세 폼 `state`(`A00_Common/design/design_structure_form.ts:278,286`) | 폼 commit | `B05_Profile_UI_Structures_Panel_Commit.ts:94` |
| 관 옵션 | 관 초안 안 `options` | 배수유역도 `facilityStore` · 횡단 캐시 culvert/ford/box 스펙 · ss `culvertopt` · ss `revetx`/`fordadjust`/`boxadjust` | 종단 `B05_Profile_UI_Drainage_Panel.ts:612` · 횡단 `B06_..._Structures_Panel.ts:277-284` · `B06_Section_UI_Page_Span_Control.ts:112` · `B06_Section_UI_Page_Ford_Controls.ts:204,398` | `design_view_cross.ts:623`(마운트 한 번) · 저장 `B06_Section_Api_Culvert_Options.ts:64` |
| 컨테이너 값 | 관 `options` 의 `soil_add_{n}_*` | ss `extrawall`(단 수) · ss `extraspan` + 캐시 `extra_spans` · ss `revetx` · 모듈 전역 `sectionsSource`/`extraCountSource`(나중에 선 보기가 이김) | `B06_Section_UI_Page_Station_Controls.ts:324` · `B06_Section_UI_Page_Span_Control.ts:78-80` · `B06_Section_UI_Page_Pipe_Options.ts:229` | `design_structure_spec.ts:112-140` · `design_view_cross.ts:298` · `design_structure_pipe_calc.ts:32,60` |
| 잇기 | 관 · 구조물 초안 `summary_item`/`group_items` | 배수유역도 `facilityStore` · ss `revetlink`(마운트 때 한 번) | `B05_Profile_UI_Drainage_Panel.ts:599-600` · `B06_..._Structures_Panel.ts:245-259` | `B05_Profile_UI_Structures_Panel_Commit.ts:114,198` |
| 관 지점 | ss `draft:pipes` | 배수유역도 `pipeEditor` + `facilityStore` · `shared.lastPipes` · 종단 보기 `lastPipes` · 횡단 패널 `pipeFacilities` · 한 벌 폼 `pipeFacilities` · 종단 다리 `pipeStations`/`pipeMarks` · 서버 `pipe_points.json` | `B05_Profile_Api_Pipes_Draft.ts:27` ← `Drainage_Panel.ts:397` · `B06_..._Structures_Panel.ts:430` | `Drainage_Panel.ts:404` · `B06_..._Structures_Panel.ts:350` · `B06_Section_Section_Store.ts:105` |
| 종단 계획선 | ss `draft:alignment-draft` | 모듈 전역 EditState Map(`B05_Profile_UI_Profile_Edit.ts:143,188`) · 한 벌 종단도 `store`/`base` | `B05_Profile_UI_Profile_Edit.ts:198` | `:97` · `B06_Section_UI_Page_Design_Sync.ts:132` |
| 횡단 설계 patch | 메모리 캐시 Map(`B06_Section/B06_Section_Section_Store.ts:178-201` · 두 보기 공유) | ss `result:section-detail` 사본(받을 때만 씀 · 고침 안 받음) · ss `crossdesign` · ss `rockb`/`cutslope`/`berm`/`crossw`/`inletstruct`/`basinadjust`/`std-cross` · 종단 보기 `currentSectionDetail` · 횡단 보기 `sectionDetail` | `B06_Section_Cross_Design_Session.ts:55` · `design_view_cross.ts:270-296,430` | `B06_Section_Cross_Refresh.ts:253`(재계산 때만) · `B06_Section_UI_Page_Persist.ts:377` · 마운트 `design_view_cross.ts:565-567,612` |
| 사토 | 구조물 목록 안 사토장 | 캐시 `design.spoil_fill_*` | 서버 저장 때만 `B06_Section_Server_Calc_Prebuild.py:197` | `B06_Section_Cross_Refresh.ts:316-327`(저장분 폭 그대로) |
| 선택 상태 | ss `draft:structure-pick` | 가게 `selection` + ss `draft:selection`(아무도 안 씀) · 횡단 `appliedPick` | `design_selection.ts:152,172` | `design_selection.ts:112` · `B05_Profile_UI_Structure_Pick_Session.ts:34` |
## ② 재현 (측정)
같은 창 안 넘김 · 저장 전
| 자료 | B05 바꿈 → B06 | B06 바꿈 → B05 |
|---|---|---|
| 구조물 옵션(돌쌓기 높이) | 맞음 — 폼 1.5 · 카드 H=1.5m | 맞음 — 1.8 |
| 관 옵션(관경) | 맞음 — 1200 | 맞음 — 800 |
| 종단 계획선(60m ▲) | 맞음 — 865.68 | 맞음 — 865.78 |
| 선택 | 맞음 — 같은 구조물 고름 | 맞음 |
| **누가토량(같은 초안)** | **어긋남 — B05 −13,478.3 · B06 −12,888.7** | 넘긴 뒤 B05 가 B06 값(−12,931.8)으로 바뀜 |
저장 · 새로고침 (B05: 높이 1.2 · 관경 1200 · ▲2 → B06: 측구 L형 · 관경 800 · 높이 1.0)
| 단계 | 높이 | 관경 | 60m 계획고 | 3+0 측구 | 누가토량 | 판정 |
|---|---|---|---|---|---|---|
| 새로고침 뒤 B05 (저장 전) | 1 | 800 | 865.68 | (B05 안 보임) | **−12,915.6** (직전 −12,931.8) | **옛 값** — L형 초안이 계산에 안 얹힘 |
| 새로고침 뒤 B05 에서 [저장] → 새 창 | 1 | 800 | 865.68 | **일반** | −13,494.6 | **옛 값** — 「편집 내용을 저장했습니다」인데 L형이 안 나감 |
| 같은 창 → B06 | 1 | 800 | 865.68 | **일반**(ss 엔 L형 초안 남음) | −13,494.6 | **옛 값** — 화면은 일반 · [저장]하면 L형이 나감(화면 ≠ 저장) |
| B06 에서 [저장] → 새 창 | 1 | 800 | 865.68 | L형 | −12,931.8 | 맞음 |
| B06 관경 1000 바꾸고 바로 [저장] → 새 창 | 1 | **800** | 865.68 | L형 | −12,931.8 | **옛 값** — `pipe-points` PUT 두 번 · 둘째(배수유역도 메모리 800)가 덮음 |
재지 않은 것(코드 읽기만): 컨테이너 값 · 잇기 · 사토
## ③ 원인
두 벌 (6)
| # | 자리 | 왜 |
|---|---|---|
| D1 | 관 목록 세 벌 — ss 초안 · 배수유역도 메모리 · 횡단 `pipeFacilities` | [저장]이 초안을 보낸 **뒤** 배수유역도 메모리를 한 번 더 PUT(`A00_Common/design/design_shared.ts:275-281` → `B05_Profile/B05_Profile_UI_Drainage_Panel.ts:636-642`) → 늦은 메모리가 새 값을 덮음 |
| D2 | 관 옵션 네 곳 — 관 초안 · 횡단 캐시 스펙 · `culvertopt` · `revetx`/`fordadjust`/`boxadjust` | 저장 차례상 `culvertopt` 가 관 초안 뒤에 나가 이김(`design_save.ts:217-222,282-285`) |
| D3 | 흙막이 단 수 두 벌 — ss `extrawall` · 관 `soil_add_{n}_*` | 컨테이너 수는 `extrawall` 을 따름 · 모듈 전역 `sectionsSource` 는 나중에 선 보기가 이김 |
| D4 | 구조물 목록 — ss 하나 + 메모리 셋 + 보일 때마다 서버 | 숨은 쪽은 보일 때 서버 + ss 로 다시 세움(`B05_Profile_UI_Page_Structures.ts:319-322` · `B06_..._Structures_Panel.ts:334-341`) |
| D5 | 종횡단 상세 — 메모리 캐시 · ss 사본 · 종단 `currentSectionDetail` · 횡단 `sectionDetail` | ss 사본은 제자리 고침을 안 받아 늘 옛 값 · 종단 쪽은 진입 때만 받음(`design_view_profile.ts:410,442`) |
| D6 | 가게 빈 칸 셋(`structures` · `sectionDetail` · `selection`) + `draft:selection` 키 | 실제 자리는 다른 키 — 「한 곳」이 겉모양뿐 · `subscribe` 구독자 0 |
다른 보기로 갈 때 덮음 · 안 얹음
- **카드 선택 초안이 재계산 길에서만 얹힘** — `crossDesignChoices` 를 읽는 곳은 `B06_Section/B06_Section_Cross_Refresh.ts:253`(계획고 재계산)과 저장(`B06_Section_UI_Page_Persist.ts:377`)뿐 → 새로고침 · 저장 뒤 카드는 서버 설계(일반)를 보이고 ss 엔 L형이 남음 (재현 2 · 3)
- **일반 [저장]은 횡단 보기가 섰을 때만 횡단 초안을 보냄** — 안 섰으면 `saveCachedCrossPatches` 가 캐시 메모리만(`B06_Section_Section_Store.ts:259`) · ss 초안(`crossdesign` · `rockb` 등) 빠짐 · 그래도 「저장했습니다」 (재현 3)
- 종단 보기 onShow 가 횡단 자료를 다시 안 읽음(`B05_Profile_UI_Page_Life.ts:30-35` · `design_view_profile.ts:548-551`) · `applyPipes(lastPipes)`(`:550`)가 끝나지 않은 메모리 목록을 폼에 실음
- 횡단 onShow 에 관 옵션 초안 다시 얹기 없음 — 마운트 때만(`design_view_cross.ts:623`)
- 종단 [저장] 뒤 캐시를 비워도(`B05_Profile_UI_Page_Actions.ts:154`) 숨은 횡단 `sectionDetail` 은 옛 객체 → `forwardPipeOptions`(`design_view_profile.ts:299`)가 버린 객체를 고침
- 관을 전부 지운 빈 초안 `[]` 을 「없음」으로 봐 서버 목록을 받음(`Drainage_Panel.ts:405-407`) → [저장]이 지운 관을 되살림
한쪽만 알림
- **구조물 · 관 바꿈이 횡단 면적에 다시 얹히는 길이 횡단 쪽에만** — `refreshDetailForStructures`(`design_view_cross.ts:143`) · 종단 보기에서 바꾸면 종단 누가토량이 옛 면적 (재현 1: 같은 초안 −13,478.3 대 −12,888.7)
- `toAll("onPipesChanged")`(`design_shared.ts:158-161`) — 횡단 쪽 묶음(`B06_Section_UI_Section_View_Profile.ts:179-216`)에 받는 칸 없음 → 실제로는 종단만 받음
- 「구조물 배치」 폼 알림은 지금 보기에만(`design_shared.ts:183`)
- 관 옵션 넘김 한 방향 — 종단 → 횡단 `forwardPipeOptions` · 횡단 → 종단은 초안 + 200ms 재읽기(`design_view_cross.ts:146`)뿐
- 숨은 종단 보기는 관 목록을 담기만(`design_view_profile.ts:153`) → 횡단에서 [저장]하면 옛 비정규 측점이 `uphill` 로 나감(`B05_Profile_UI_Page_Actions.ts:128-134`)
## ④ 한 곳으로 모을 갈래 (8 · 작은 것부터)
목표: 자료 하나 = 세션 키 하나(가게 칸 하나) · 두 보기는 읽기만 · 바꿈 알림 한 길 · 보기 넘길 때 서버로 덮지 않음 · [저장]은 세션 키를 그대로 보냄(5장 데이터 3층)
| 차례 | 갈래 | 크기 | 고치는 재현 |
|---|---|---|---|
| 1 | 저장 줄기 관 한 번 — 둘째 `side.savePipes()` PUT 을 빼고 초안 하나만(시설 되붙이기는 초안 쪽에서) · `design_shared.ts:275-281` | 작음 | 관경 바로 저장 옛 값 |
| 2 | 카드 선택 초안(`crossdesign` · `rockb` · `cutslope` 등)을 상세를 **불러올 때 한 번** 캐시에 얹음(`B06_Section_Section_Store.ts` load) — 재계산 길에만 두지 않음 | 작음~중간 | 새로고침 뒤 카드 일반 · B05 누가토량 어긋남 |
| 3 | 일반 [저장]이 보기와 무관하게 횡단 ss 초안을 늘 보냄(`saveCachedCrossPatches` 가 ss 초안 포함) | 작음~중간 | B05 [저장] L형 빠짐 |
| 4 | ss `result:section-detail` 사본 — 제자리 고침을 같이 쓰거나 지움 | 작음 | 새로고침 뒤 옛 사본 |
| 5 | 구조물 · 관 바꿈 → 횡단 상세 벽 · 면적 다시 얹기를 공용 한 길로(어느 보기에서 바꿔도) · 종단 `currentSectionDetail` 대신 `Section_Store` 캐시 하나만 읽음 | 중간 | 같은 초안 누가토량 두 값 |
| 6 | 관 목록 한 벌 — 배수유역도 메모리 · 횡단 `pipeFacilities` · `shared.lastPipes` 를 ss 초안 읽기 전용으로 · `onPipesChanged` 를 두 보기 모두 · 빈 초안 `[]` 도 초안으로 | 중간 | 관 지점 두 벌 |
| 7 | 관 옵션 한 자리 — `culvertopt` · `revetx` · `extrawall` 을 관 초안 `options` 로 모음 · 흙막이 단 수 한 벌 · 저장 차례 겹침 없앰 | 큼 | 관 옵션 네 곳 |
| 8 | 가게를 실제 한 창구로 — 자료마다 키 하나 · `subscribe` 알림으로 두 보기 다시 그림 · 빈 칸 정리 · 보기 onShow 서버 재조회는 초안 없을 때만 | 큼 | 두 벌 전반 |
@@ -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
+109
View File
@@ -0,0 +1,109 @@
"""54-22 보안 — H1 구조물 id 경로 넘기 · H2 지면 필터 · 표현 이름 경로 넘기.
못박는 것
- H1: 구조물 저장 `structure_id` 는 안전 이름만(어기면 검사 실패 = 422) · 복사본 맞춤은
규칙 밖 · `base` 밖 폴더를 지우지도 쓰지도 않음.
- H2: `filter_key` · `method` 는 허용 목록만(스키마) · 파일 이름 만드는 곳에서도 한 번 더.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from B05_Profile.B05_Profile_Engine_Skeleton import load_or_build_skeleton
from B05_Profile.B05_Profile_Engine_Solver import _load_dtm_grid, _source_npz_paths
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteSolveRequest
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from common_util.common_util_surface_sampler import build_surface_sampler, check_surface_keys
from M02_MasterTemplete import M02_Item_Snapshot as snap
BAD_IDS = ["../../..", "..", "a/b", "a\\b", "C:/Windows", "/etc", ".hidden", "", "x" * 65]
BAD_KEYS = ["../other/dtm_csf", "csf/../../x", "C:/x", "", "CSF"]
def _structure(structure_id):
return StructureInstance(
structure_id=structure_id, type_id="x", placement="point", chainage_m=1
)
@pytest.mark.parametrize("bad", BAD_IDS)
def test_H1_악성_id_거절(bad):
with pytest.raises(ValidationError):
_structure(bad)
@pytest.mark.parametrize("good", [None, "0c3d99cc5d8f49139c871d662537da42", "abc_1-2", "s1__g1"])
def test_H1_정상_id_통과(good):
assert _structure(good).structure_id == good
def test_H1_safe_folder(tmp_path):
base = tmp_path / "templates" / "structures"
base.mkdir(parents=True)
assert snap.safe_folder(base, "abc123") == base / "abc123"
assert snap.safe_folder(base, "pipe@40.0__gin") == base / "pipe@40.0__gin"
for bad in BAD_IDS[:-1] + [str(tmp_path)]: # 길이는 스키마 몫
assert snap.safe_folder(base, bad) is None
def test_H1_맞춤이_base_밖을_안_건드림(tmp_path, monkeypatch):
root = tmp_path / "proj"
base = snap.structures_dir(root)
base.mkdir(parents=True)
victim = root / "victim" # base / "../../victim"
victim.mkdir()
(victim / "keep.txt").write_text("살아야 함", encoding="utf-8")
item = {"layer": "master", "owner": "", "key": "k"}
monkeypatch.setattr(
snap, "assignments", lambda _root: {"../../victim": item, str(victim): item, "ok1": item}
)
def fake_write(target: Path, *_args):
target.mkdir(parents=True, exist_ok=True)
return {}
monkeypatch.setattr(snap, "write_snapshot", fake_write)
result = snap.sync_snapshots(root)
assert (victim / "keep.txt").is_file()
assert set(result["못만듦"]) == {"../../victim", str(victim)}
assert result["만듦"] == ["ok1"]
assert result["지움"] == []
@pytest.mark.parametrize("bad", BAD_KEYS)
def test_H2_스키마_허용_목록(bad):
body = {"bp": {"x": 0, "y": 0}, "ep": {"x": 1, "y": 1}}
with pytest.raises(ValidationError):
RouteSolveRequest(filter_key=bad, **body)
with pytest.raises(ValidationError):
RouteSolveRequest(filter_key="csf", method=bad, **body)
with pytest.raises(ValidationError):
RouteConfirmRequest(filter_key=bad)
with pytest.raises(ValidationError):
RouteConfirmRequest(method=bad)
def test_H2_스키마_정상값_통과():
assert RouteConfirmRequest(filter_key="grid_min_z", method="tin").method == "tin"
assert RouteConfirmRequest().filter_key is None
@pytest.mark.parametrize("bad", BAD_KEYS)
def test_H2_파일_이름_만드는_곳(bad, tmp_path):
with pytest.raises(ValueError):
check_surface_keys(bad)
with pytest.raises(ValueError):
check_surface_keys("csf", bad)
with pytest.raises(ValueError):
build_surface_sampler(tmp_path, bad, "dtm", False)
with pytest.raises(ValueError):
_load_dtm_grid(tmp_path, bad, False)
with pytest.raises(ValueError):
_source_npz_paths(tmp_path, "csf", bad, False)
with pytest.raises(ValueError):
load_or_build_skeleton(tmp_path, bad, "dtm", False)
check_surface_keys("classification", "meshfree")
@@ -71,7 +71,7 @@ def test_request_wins_over_stored():
def test_schema_defaults_to_none_and_ships_the_flag():
"""스키마 기본은 None(미지정) — 저장값을 덮어쓰지 않는다."""
request = RouteSolveRequest(
filter_key="f",
filter_key="csf",
bp={"x": 0.0, "y": 0.0},
ep={"x": 100.0, "y": 0.0},
)
@@ -0,0 +1,255 @@
/* 54-13 — 좌측 폼 고르기 칸이 그림이 쓰는 값(기본값 · 자동 규칙 결과)을 보이는가 · 보여 주기만으로 값이 안 바뀌는가
* 시작 화면: B06 횡단설계(#/b06-section) 또는 B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 로그인
* 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5413 = "단계";` · 결과 `sessionStorage.getItem("__5413_out_{단계}")`
* check → 목록의 배관 · 세월교 · BOX 전부를 열어 폼 글 ↔ 그림(B06 카드 SVG 글 · 벽 툴팁 · 상세 세트) 대조 ·
* B05 에서 부르면 B06 에서 읽은 폼 글과 같은지 · 초안(관 지점 세션) 안 생김
* old → 4+5 배관을 옛 「자동(규칙)」(유입 기슭막이 · 형식 없음)으로 초안에 넣고 새로 고침
* oldpick→ (새로 고친 뒤) 폼 = 그림 · 관경만 고르면 유입 형식 안 굳음 · 흙막이 고르면 revet · 그림 벽 ·
* 12+18 배관 집수정 높이 고침 = 집수정을 고른 것
* box → 17+12 세월교(빈 칸) 대조 · 세션에서만 BOX 로 바꾸고 칸을 비워 규격 · 유입 · 유출 = 기본 · 저장 칸 없음
* restore→ 초안 지우고 새로 고침 · 서버 관 지점 = 처음 사진
* 쓰는 것: sessionStorage 만(서버 저장 없음)
* 기대 수치: 모든 단계 bad = []
*/
(async () => {
const MODE = window.__m5413 || "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 DRAFT = `aislo:draft:pipes:${pid}`;
const B06 = location.hash.includes("b06");
const txt = (el) => el.innerText.replace(/\s+/g, " ").trim();
const flat = (s) => String(s ?? "").replace(/\s+/g, "");
const out = { mode: MODE, page: B06 ? "B06" : "B05", bad: [] };
const bad = (what) => out.bad.push(what);
const done = () => sessionStorage.setItem(`__5413_out_${MODE}`, JSON.stringify(out));
const api = async () =>
(await (await fetch(`/api/projects/${pid}/drainage/pipe-points`)).json()).pipe_points;
const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const rowOf = (head) => rows().find((li) => txt(li).startsWith(head));
const draft = (at) => {
const raw = sessionStorage.getItem(DRAFT);
const list = raw ? (JSON.parse(raw).value ?? JSON.parse(raw)) : null;
return list?.find((p) => Math.abs(p.chainage_m - at) < 0.51) ?? null;
};
const spec = () =>
[...document.querySelectorAll(".b05-structure__spec")].find((s) => s.offsetParent);
/** 폼 고르기 칸 `{항목: 보이는 글}` · 꼬리 표시(data-spec-shown) 칸 목록. */
const formOf = () => {
const fields = {};
const tagged = [];
for (const wrap of spec()?.querySelectorAll("[data-spec-item]") ?? []) {
const sel = wrap.querySelector("select");
if (!sel) continue;
fields[wrap.dataset.specItem] = sel.options[sel.selectedIndex]?.text ?? "";
if (wrap.dataset.specShown) tagged.push(wrap.dataset.specItem);
}
return { fields, tagged };
};
const detailAt = (at) => {
const key = Object.keys(sessionStorage).find((k) => k.includes("section-detail"));
const raw = key ? JSON.parse(sessionStorage.getItem(key)) : null;
const list = (raw?.value ?? raw)?.cross_sections ?? [];
return list.reduce(
(a, b) => (Math.abs(b.chainage_m - at) < Math.abs((a?.chainage_m ?? 1e9) - at) ? b : a),
null,
);
};
/** 그림 — 고른 카드 SVG 글 · 벽 툴팁(역할 + 형태). */
const drawn = () => {
const svg = document.querySelector(".b06-cross-card--selected svg");
if (!svg) return null;
return {
labels: [...svg.querySelectorAll(".b06-chart__culvert-label")].map((t) => t.textContent),
walls: [...svg.querySelectorAll(".b06-chart__culvert-revet title")].map(
(t) => t.textContent.split(" H=")[0],
),
};
};
const open = async (head) => {
const row = await until(() => rowOf(head)).then(() => rowOf(head));
if (!row) return (bad(`${head}: 목록에 없음`), false);
row.click();
await until(() => spec() && (!B06 || document.querySelector(".b06-cross-card--selected svg")));
await w(1200);
return true;
};
const fire = (sel, value) => {
sel.value = value;
sel.dispatchEvent(new Event("change", { bubbles: true }));
};
const selectOf = (item) => spec()?.querySelector(`[data-spec-item="${item}"] select`);
const SHAPE = { I형: "I형", L형: "ㄴ형", ㄷ형: "ㄷ형" };
/** 폼 글 ↔ 그림 — 종류마다 그림이 쓰는 값을 따로 읽어 대조. */
const compare = (head, at, kind) => {
const { fields, tagged } = formOf();
const pic = B06 ? drawn() : null;
const set = detailAt(at);
const got = { head, fields, tagged, pic };
const blank = Object.entries(fields).filter(
([k, v]) => v === "— 안 정함 —" && !/^(soil_add|chute_add)/.test(k),
);
if (blank.length) bad(`${head}: 빈 칸 글 ${blank.map(([k]) => k)}`);
if (!pic) return got; // B05 — 글 대조는 B06 사진과
const head0 = (v) => flat(v).replace(/\((기본|자동)\)$/, "");
if (kind === "pipe") {
const basin = pic.labels.find((l) => l.startsWith("집수정"));
const inlet = head0(fields["inlet.structure"]);
if ((inlet === "집수정") !== !!basin) bad(`${head}: 유입 ${inlet} ↔ 그림 ${basin ?? "벽"}`);
const form = head0(fields["inlet.form"]);
if (basin && !basin.includes(SHAPE[form] ?? "?")) bad(`${head}: 모양 ${form} ↔ ${basin}`);
for (const [side, role] of [
["inlet", "유입"],
["outlet", "유출"],
]) {
const wall = pic.walls.find((t) => t.startsWith(`${role} 기슭막이`));
if (side === "inlet" && basin) continue;
const f = head0(fields[`${side}.form`]);
if (!wall || flat(wall) !== flat(`${role} 기슭막이 ${f}`))
bad(`${head}: ${side} 형태 ${f} ↔ ${wall}`);
}
const d = Math.round((set?.culvert?.diameter_m ?? 0) * 1000);
if (tagged.includes("pipe.diameter") && head0(fields["pipe.diameter"]) !== String(d))
bad(`${head}: 관경 ${fields["pipe.diameter"]} ↔ ${d}`);
}
if (kind === "ford") {
const label = pic.labels.find((l) => l.includes("Ø")) ?? "";
if (!label.includes(`Ø${head0(fields["ford.diameter"])}`))
bad(`${head}: 관경 ${fields["ford.diameter"]} ↔ ${label}`);
for (const side of ["in", "out"]) {
const wing = set?.ford?.[`wing_${side}`]?.installed;
const text = head0(fields[`${side === "in" ? "inlet" : "outlet"}.structure`]);
if (wing !== undefined && (text === "날개벽") !== wing)
bad(`${head}: ${side} ${text} ↔ 설치 ${wing}`);
}
}
if (kind === "box") {
const label = pic.labels.find((l) => l.startsWith("BOX")) ?? "";
if (flat(label) !== `BOX${head0(fields["body.size"])}`)
bad(`${head}: 규격 ${fields["body.size"]} ↔ ${label}`);
}
return got;
};
const KIND = { 배관: "pipe", 세월교: "ford", BOX암거: "box" };
try {
if (MODE === "check") {
if (!sessionStorage.getItem("__5413_orig"))
sessionStorage.setItem("__5413_orig", JSON.stringify(await api()));
const before = sessionStorage.getItem(DRAFT);
const heads = rows()
.map(txt)
.filter((t) => /^\d+\+\d+ (배관|세월교|BOX암거)/.test(t));
const b06 = JSON.parse(sessionStorage.getItem("__5413_b06") ?? "{}");
out.rows = [];
for (const t of heads) {
const head = t.split(" ").slice(0, 2).join(" ");
if (!(await open(head))) continue;
const station = head.split(" ")[0].split("+").map(Number);
const got = compare(head, station[0] * 20 + station[1], KIND[t.split(" ")[1]]);
out.rows.push(`${head} ${JSON.stringify(got.fields)} | ${JSON.stringify(got.pic)}`);
if (B06) b06[t] = got.fields;
else if (b06[t] && JSON.stringify(b06[t]) !== JSON.stringify(got.fields))
bad(`${head}: B05 ${JSON.stringify(got.fields)} ≠ B06 ${JSON.stringify(b06[t])}`);
else if (!b06[t]) bad(`${head}: B06 사진 없음 — B06 에서 check 먼저`);
}
if (B06) sessionStorage.setItem("__5413_b06", JSON.stringify(b06));
if (sessionStorage.getItem(DRAFT) !== before) bad("열기만 했는데 관 초안이 바뀜");
out.count = heads.length;
} else if (MODE === "old") {
const list = (await api()).map(({ lonlat, ...rest }) => rest);
const pipe = list.find((p) => Math.abs(p.chainage_m - 85.05) < 0.51);
pipe.options = { ...pipe.options, inlet_type: "기슭막이" };
delete pipe.options.inlet_structure;
sessionStorage.setItem(DRAFT, JSON.stringify(list));
out.note = "초안 넣음 — 새로 고친 뒤 oldpick";
done();
location.reload();
return;
} else if (MODE === "oldpick") {
await open("4+5 배관");
out.start = compare("4+5 옛 자동", 85.05, "pipe");
if (!flat(out.start.fields["inlet.structure"]).endsWith("(자동)"))
bad(`옛 자동 유입 글 ${out.start.fields["inlet.structure"]}`);
// 관경만 고름 — 유입 형식은 그대로(자동 유지)
fire(selectOf("pipe.diameter"), "1200");
await w(2500);
out.afterDiameter = draft(85.05)?.options;
if (out.afterDiameter?.inlet_structure !== undefined)
bad(`관경만 골랐는데 형식 ${out.afterDiameter.inlet_structure}`);
if (out.afterDiameter?.inlet_type !== "기슭막이")
bad(`유입 저장 값 바뀜 ${out.afterDiameter?.inlet_type}`);
out.stillAuto = formOf().fields["inlet.structure"];
// 흙막이를 고름 = 형식 revet · 그림 벽
fire(selectOf("inlet.structure"), "기슭막이");
await w(3500);
out.afterPick = draft(85.05)?.options;
if (out.afterPick?.inlet_structure !== "revet")
bad(`흙막이 골랐는데 형식 ${out.afterPick?.inlet_structure}`);
out.picked = compare("4+5 흙막이 고름", 85.05, "pipe");
if (out.picked.fields["inlet.structure"] !== "흙막이")
bad(`고른 글 ${out.picked.fields["inlet.structure"]}`);
// 다른 관(자동 집수정) — 집수정 높이 고침 = 집수정을 고른 것
await open("12+18 배관");
const shown = formOf().fields["inlet.structure"];
out.other = shown;
if (flat(shown) === "집수정(자동)") {
const h = spec().querySelector('[data-spec-item="inlet.height"] input');
h.value = "1.5";
h.dispatchEvent(new Event("change", { bubbles: true }));
await w(3000);
const o = draft(258.12)?.options;
out.basinEdit = o;
if (o?.inlet_type !== "집수정" || o?.inlet_basin_height_m !== 1.5)
bad(`집수정 높이 고침 저장 ${JSON.stringify(o)}`);
if (formOf().fields["inlet.structure"] !== "집수정")
bad(`높이 고친 뒤 글 ${formOf().fields["inlet.structure"]}`);
}
} else if (MODE === "box") {
await open("17+12 세월교");
out.ford = compare("17+12 세월교", 352.14, "ford");
const type = document.querySelectorAll(
".b05-structure-section .b05-structure__grid select",
)[1];
fire(type, "box_culvert");
await until(() => rowOf("17+12 BOX암거"));
await w(3000);
// 새 종류는 정의 기본값이 값으로 실림(46-7) — 칸을 비워 옛 빈 저장과 같게
for (const k of ["body.size", "inlet.structure", "outlet.structure"]) {
fire(selectOf(k), "");
await w(1500);
}
await w(2000);
out.box = compare("17+12 BOX", 352.14, "box");
const f = out.box.fields;
if (flat(f["body.size"]) !== "2.0×2.0(기본)") bad(`BOX 규격 ${f["body.size"]}`);
for (const k of ["inlet.structure", "outlet.structure"])
if (flat(f[k]) !== "날개벽(기본)") bad(`BOX ${k} ${f[k]}`);
const o = draft(352.14)?.options ?? {};
if (["body_width_m", "wing_in", "wing_out"].some((k) => k in o))
bad(`보여 주기만 했는데 BOX 저장 칸 ${JSON.stringify(o)}`);
} else if (MODE === "restore") {
sessionStorage.removeItem(DRAFT);
// 고르기 단계가 남긴 B06 유입 형식 세션 · 그걸 얹은 상세 캐시(다시 받음)
for (const key of Object.keys(sessionStorage))
if (key.includes(`inletstruct:${pid}`) || key.includes(`section-detail:${pid}`))
sessionStorage.removeItem(key);
const orig = JSON.parse(sessionStorage.getItem("__5413_orig") ?? "null");
const now = await api();
out.same = JSON.stringify(now) === JSON.stringify(orig);
if (!out.same) bad("서버 관 지점이 처음 사진과 다름");
done();
location.reload();
return;
}
} catch (error) {
bad(String(error?.stack ?? error));
}
done();
})();
@@ -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;
})();
@@ -0,0 +1,242 @@
/* 54-15 — 구조물 목록 잇기 램프(측점 왼쪽 · 3행 × 열 · 초록 이음 · 빨강 필수 미지정 · 노랑 선택 미지정)
* 시작 화면: B06(#/b06-section) 또는 B05 · 검증 프로젝트(sub4_검증) · 로그인
* 부르는 법: orca eval --expression "window.__m5415={mode:'단계'}; $(cat 이 파일)"
* look → 줄마다 램프 = 폼 잇기 줄(수 · 색) 대조 · 줄 높이(램프 끈 때와 같음) · 글 시작 x · 칸 폭
* live → ARGS.row(기본 「배관 1」) 빨강 하나 잇기 → 초록 → 해제 → 빨강 · 「도수로/돌붙임」 추가 →
* 노랑 → 잇기 → 초록 → 해제 → 노랑 → 빼기 → 램프 수 되돌아옴(저장 안 함)
* sig → 줄마다 램프 색 글(B05 · B06 대조 · 저장 · 다시 열기 대조용)
* (결과는 window.__m5415_out 에도 — orca eval 응답이 끊기면 그것을 읽음)
* link / unlink → ARGS.row 첫 빨강 잇기 / 첫 초록 해제 → [저장] → sig
*/
(async () => {
const ARGS = window.__m5415 || { mode: "look" };
const ROW = ARGS.row || "배관 1";
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 20000) => {
for (let t = 0; t < ms; t += 200) {
if (fn()) return true;
await w(200);
}
return false;
};
const modUrl = (part) =>
performance
.getEntriesByType("resource")
.map((e) => e.name)
.filter((n) => n.includes(part))
.pop();
// 명세 모듈 주소 — 자원 목록에 없으면(먼저 불린 계산 모듈 주소에서) 이름만 바꿔 씀
const specUrl =
modUrl("/design/design_structure_pipe.ts") ??
modUrl("/design/design_structure_pipe_calc.ts").replace("_calc.ts", ".ts");
const { STRUCTURE_SPECS } = await import(specUrl);
const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")];
const nameOf = (li) => li.querySelector("span")?.textContent ?? "";
const rowOf = (name) => items().find((li) => nameOf(li) === name);
const color = (dot) =>
dot.classList.contains("is-linked") ? "G" : dot.classList.contains("is-required") ? "R" : "Y";
const lampsOf = (li) => [...li.querySelectorAll(".b05-route__lamps i")].map(color).join("");
const visible = (n) => n.offsetParent !== null;
/** 폼에 보이는 잇기 줄 — 색 글(같은 차례 · 위 → 아래). */
const formLinks = () =>
[...document.querySelectorAll(".b05-structure__summary")].filter(visible).map((row) => {
if (row.querySelector(".b05-structure__summary-name.is-assigned")) return "G";
const box = row.closest("[data-spec-container]");
if (!box) return "R"; // 종류 단위 · 정의 그룹 상자 = 필수
const c = Object.values(STRUCTURE_SPECS)
.flatMap((s) => s.containers)
.find(
(c) => c.id === box.dataset.specContainer && row.dataset.specLink?.startsWith(c.link),
);
return c && !c.required ? "Y" : "R";
});
const sorted = (s) => [...s].sort().join("");
const select = async (name) => {
await until(() => items().length > 0);
const li = rowOf(name);
if (!li) return false;
if (!li.classList.contains("is-selected")) {
li.click();
await until(() => rowOf(name)?.classList.contains("is-selected"), 5000);
}
await w(700);
return true;
};
const release = async () => {
const li = document.querySelector(".b05-route__irregular-list li.is-selected");
if (li) li.click();
await w(500);
};
const sig = () =>
items().map((li) => `${li.querySelector("strong")?.textContent}${nameOf(li)}:${lampsOf(li)}`);
const pickLink = async (row) => {
row.querySelector('[data-b05-summary="pick"]').click();
await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row"));
for (let k = 0; k < 8; k++) {
const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")];
const leaf = rows.find((r) => !r.classList.contains("is-branch"));
if (leaf) {
leaf.click();
break;
}
rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click();
await w(300);
}
await w(300);
[...document.querySelectorAll(".ui-item-picker__actions button")]
.find((b) => b.textContent.includes("적용"))
.click();
await w(1500);
};
const linkRows = () => [...document.querySelectorAll(".b05-structure__summary")].filter(visible);
const save = async () => {
document.querySelector('[data-dock-action="save"]').click();
await w(500);
const ok = await until(() => !document.querySelector(".ui-overlay.is-active"), 90000);
await w(1500);
return ok;
};
const out = { mode: ARGS.mode };
if (ARGS.mode === "look") {
await release();
const list = document.querySelector(".b05-route__irregular-list");
const heights = () =>
items().map((li) => Math.round(li.getBoundingClientRect().height * 10) / 10);
const withLamps = heights();
list.classList.add("m5415-off");
const style = document.createElement("style");
style.textContent = ".m5415-off .b05-route__lamps{display:none}";
document.head.append(style);
const without = heights();
style.remove();
list.classList.remove("m5415-off");
const xs = items().map(
(li) => Math.round(li.querySelector("strong").getBoundingClientRect().left * 10) / 10,
);
const widths = items().map(
(li) => li.querySelector(".b05-route__lamps").getBoundingClientRect().width,
);
const dot = items()
.map((li) => li.querySelector(".b05-route__lamps i"))
.find(Boolean)
?.getBoundingClientRect();
// 줄마다 폼을 열어 잇기 줄과 대조
const rows = [];
for (const li of items()) {
const name = nameOf(li);
const lamps = lampsOf(li);
await select(name);
const form = formLinks().join("");
rows.push({ name, lamps, form, ok: sorted(lamps) === sorted(form) });
await release();
}
const cols = Math.max(...rows.map((r) => Math.ceil(r.lamps.length / 3)));
Object.assign(out, {
rows: rows.length,
mismatch: rows.filter((r) => !r.ok),
lampTotal: rows.reduce((n, r) => n + r.lamps.length, 0),
colors: Object.fromEntries(
["G", "R", "Y"].map((c) => [
c,
rows
.map((r) => r.lamps)
.join("")
.split(c).length - 1,
]),
),
byKind: [...new Set(rows.map((r) => `${r.name.replace(/ \d+$/, "")}=${r.lamps.length}`))],
heightSame: JSON.stringify(withLamps) === JSON.stringify(without),
heights: [...new Set(withLamps)],
textX: [...new Set(xs)],
lampWidth: [...new Set(widths)],
cols,
dot: dot && [dot.width, dot.height],
dotColors: ["is-linked", "is-required", "is-optional"].map((c) => {
const d = document.querySelector(`.b05-route__lamps i.${c}`);
return d ? getComputedStyle(d).backgroundColor : null;
}),
});
}
if (ARGS.mode === "live") {
await select(ROW);
const lamps = () => lampsOf(rowOf(ROW));
const steps = [["start", lamps(), formLinks().join("")]];
const red = linkRows().find(
(r) =>
!r.querySelector(".is-assigned") &&
!r.closest("[data-spec-container]")?.querySelector("[data-spec-remove]"),
);
if (red) {
await pickLink(red);
steps.push(["link", lamps(), formLinks().join("")]);
red.querySelector('[data-b05-summary="clear"]').click();
await w(1500);
steps.push(["clear", lamps(), formLinks().join("")]);
}
const add = [...document.querySelectorAll("[data-spec-add]")].find(visible);
if (add) {
const id = add.dataset.specAdd;
add.click();
await w(1500);
steps.push([`add ${id}`, lamps(), formLinks().join("")]);
// 램프 4 개 → 2 열 — 칸 폭 · 글 시작 x · 줄 높이가 목록 전체에서 하나
const box2 = (sel) => [
...new Set(
items().map(
(li) =>
Math.round(
li.querySelector(sel).getBoundingClientRect()[sel === "strong" ? "left" : "width"] *
10,
) / 10,
),
),
];
out.twoCols = {
lampWidth: box2(".b05-route__lamps"),
textX: box2("strong"),
heights: [
...new Set(items().map((li) => Math.round(li.getBoundingClientRect().height * 10) / 10)),
],
};
const box = [...document.querySelectorAll(`[data-spec-container="${id}"]`)]
.filter(visible)
.at(-1);
await pickLink(box);
steps.push(["link", lamps(), formLinks().join("")]);
box.querySelector('[data-b05-summary="clear"]').click();
await w(1500);
steps.push(["clear", lamps(), formLinks().join("")]);
[...document.querySelectorAll(`[data-spec-container="${id}"]`)]
.filter(visible)
.at(-1)
.querySelector("[data-spec-remove]")
.click();
await w(1500);
steps.push(["remove", lamps(), formLinks().join("")]);
}
out.steps = steps.map(([s, l, f]) => `${s}: ${l} (폼 ${f})`);
out.back = steps[0][1] === steps.at(-1)[1];
await release();
}
if (ARGS.mode === "link" || ARGS.mode === "unlink") {
await select(ROW);
const target = linkRows().find((r) =>
ARGS.mode === "link" ? !r.querySelector(".is-assigned") : r.querySelector(".is-assigned"),
);
if (ARGS.mode === "link") await pickLink(target);
else {
target.querySelector('[data-b05-summary="clear"]').click();
await w(1500);
}
out.row = lampsOf(rowOf(ROW));
out.saved = await save();
await release();
}
out.sig = sig();
window.__m5415_out = JSON.stringify(out); // 응답이 끊겨도 뒤에 읽음
return window.__m5415_out;
})();
@@ -0,0 +1,83 @@
/* 54-20 — B02~B09 좌측 패널 아래 공통 단추 묶음([?] · [초기화] · [저장] · [확정] · [횡단 보기] …) 치수
* 시작 화면: 아무 B 화면(로그인 · 환경 배정 storage 프로젝트가 현재 프로젝트)
* 부르는 법: orca eval --expression "$(cat 이 파일)" · 1분 넘으면 끊기므로
* `window.__btn5420 = ["b05-profile", …];` 를 앞에 붙여 나눠 부름
* 쓰는 것: 없음(주소 이동 · 재기만 · 끝나면 처음 주소로)
* 기대 수치:
* - 줄마다 보이는 단추 h 하나 · fs 하나 · ff 하나 · 줄 안 gap 하나 · wrap = false · over = false
* - [?] = 정사각(w = h)
* - 좁은 패널(narrow = true 로 패널 폭을 최소로 줄여 다시 잼)에서도 wrap · over 없음
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
const vis = (n) => n && n.offsetParent !== null;
const start = location.hash;
const routes = window.__btn5420 ?? [
"b02-proj-register",
"b03-file-input",
"b04-preprocess",
"b05-profile",
"b06-section",
"b07-design-detail",
"b08-quantity",
"b09-estimation",
];
const measure = () => {
const help = [...document.querySelectorAll(".ui-help-btn")].find(vis);
const rows = help
? [help.closest(".ui-sidebar-actions") ?? help.parentElement]
: [...document.querySelectorAll(".ui-sidebar-actions")].filter(vis);
return rows.map((row) => {
const btns = [...row.querySelectorAll("button")].filter(vis);
const rr = row.getBoundingClientRect();
return {
row: row.className,
rowW: Math.round(rr.width),
gap: getComputedStyle(help?.parentElement ?? row).gap,
btns: btns.map((b) => {
const s = getComputedStyle(b);
const r = b.getBoundingClientRect();
const text = document.createRange();
text.selectNodeContents(b);
return {
t: b.innerText.trim(),
cls: b.className,
h: +r.height.toFixed(1),
w: +r.width.toFixed(1),
fs: s.fontSize,
fw: s.fontWeight,
ff: s.fontFamily.split(",")[0],
pad: s.padding,
bd: s.borderWidth + " " + s.borderStyle,
rad: s.borderRadius,
top: +(r.top - rr.top).toFixed(1),
wrap: text.getBoundingClientRect().height > parseFloat(s.fontSize) * 1.6,
// 글이 단추 안쪽(여백 뺀 폭)을 넘거나 단추가 줄 밖으로 나감
over:
text.getBoundingClientRect().width >
b.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight) + 0.5 ||
r.right > rr.right + 1,
};
}),
};
});
};
const out = [];
for (const route of routes) {
location.hash = `#/${route}`;
await w(4500);
const res = { page: route.slice(0, 3).toUpperCase(), rows: measure() };
const panel = document.querySelector(".ui-workflow-overlay__panel--title");
if (window.__btn5420narrow && panel) {
const was = panel.style.width;
panel.style.width = "var(--wf-left-panel-narrow-width)"; // 좁은 창(860px 아래) 폭
await w(400);
res.narrowW = Math.round(panel.getBoundingClientRect().width);
res.narrow = measure();
panel.style.width = was;
}
out.push(res);
}
location.hash = start;
return JSON.stringify(out);
})();
+4 -3
View File
@@ -2,9 +2,10 @@
.ui-sidebar-actions .ui-help-btn,
.ui-help-btn {
flex: none;
width: auto;
min-width: 2.25rem;
padding-inline: var(--spacing-12);
/* 정사각 — 너비 = 높이(글 한 줄 · 위아래 여백 · 테두리) */
width: calc(1em + 2 * var(--spacing-8) + 2px);
min-width: 0;
padding-inline: 0;
}
.ui-help.ui-modal {
+15
View File
@@ -368,6 +368,21 @@
min-width: 0;
}
/* 아래 단추 묶음(54-20) — B03~B09 한 양식 · 사이 = --btn-group-gap · 고르게 늘되 글보다 좁아지지
않음 · 좁은 패널(280px)에서도 한 줄. 단추 줄 = [data-help-row](없으면 묶음 자신) */
.ui-sidebar-actions:not(:has([data-help-row])),
.ui-sidebar-actions [data-help-row] {
gap: var(--btn-group-gap);
}
.ui-sidebar-actions > .ui-btn:not(.ui-help-btn),
.ui-sidebar-actions [data-help-row] > .ui-btn:not(.ui-help-btn) {
flex: 1 1 0;
min-width: auto;
padding-inline: var(--spacing-4);
white-space: nowrap;
}
/* 사이드 패널 내부 컨테이너 공통 외곽선 — 기본 --color-border가 두 테마 모두 흐려
안 보인다는 보고(2026-08-05). 본문 보조색을 섞어 어느 테마에서든 뚜렷하게.
45%는 아직 흐리다는 재보고(2026-08-06)로 보조색 비중을 75%로 올렸다 — 테마 변수