Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%로 올렸다 — 테마 변수
|
||||
|
||||
Reference in New Issue
Block a user