Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1

This commit is contained in:
2026-10-10 14:11:39 +09:00
51 changed files with 2130 additions and 222 deletions
+2
View File
@@ -177,6 +177,8 @@ export const STATE_REGISTRY = {
crossdesign: { bucket: "draft", scope: "route" },
/** 설계선 쉬프트 = 쪽마다 노견 확장 `{측점: {left, right}}`(139-6) — [저장] 때 patch 로. */
shoulderext: { bucket: "draft", scope: "route" },
/** 소단 쪽마다 옮긴 깊이 `{측점: {left, right}}`(194-1 · 횡단도 ▲▼) — [저장] 때 patch 로. */
bermdepth: { bucket: "draft", scope: "route" },
/** B08 자재 집계표 셋 사용자 값 `{units, mix, surcharge}`(고친 칸만) — [저장] 때 프로젝트 설정으로(121). */
"b08-materials": { bucket: "draft", scope: "project" },
/** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */
+6
View File
@@ -455,6 +455,9 @@ export interface CrossDesign {
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6) — 사용자 값 · 노체 · 측구 · 사면 · 구조물 같이. */
shoulder_extend_left_m?: number;
shoulder_extend_right_m?: number;
/** 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · 194-1) — 사용자 값 · 기하 입력. */
berm_depth_left_m?: number;
berm_depth_right_m?: number;
cross_slope_pct: number;
ditch:
| { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number }
@@ -578,6 +581,9 @@ export interface CrossSectionPatch {
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림. */
shoulder_extend_left_m?: number;
shoulder_extend_right_m?: number;
/** 소단 쪽마다 옮긴 깊이(m · 194-1 · 횡단도 ▲▼). */
berm_depth_left_m?: number;
berm_depth_right_m?: number;
/** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */
pipe_length_m?: number;
/** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */
+36 -14
View File
@@ -74,15 +74,14 @@ export function crossDesignChoices(
export type ShoulderSide = "left" | "right";
export type ShoulderExtends = Map<number, Partial<Record<ShoulderSide, number>>>;
/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */
export function readShoulderExtends(
projectId: string | null,
routeId: number | null,
): ShoulderExtends {
type SideState = "shoulderext" | "bermdepth";
/** 누가거리(숫자) → 쪽마다 값(세션에 있는 쪽만). */
function readSides(name: SideState, projectId: string | null, routeId: number | null) {
const out: ShoulderExtends = new Map();
if (!projectId || routeId === null) return out;
const raw = readState<Record<string, Partial<Record<ShoulderSide, number>>>>(
"shoulderext",
name,
projectId,
routeId,
);
@@ -93,8 +92,8 @@ export function readShoulderExtends(
return out;
}
/** 한 측점 한 쪽 노견 확장을 담는다(0 = 되돌림 · 저장 때 정본의 옛 값을 지움). */
export function writeShoulderExtend(
function writeSide(
name: SideState,
projectId: string,
routeId: number,
chainageM: number,
@@ -102,11 +101,34 @@ export function writeShoulderExtend(
valueM: number,
): void {
const all =
readState<Record<string, Partial<Record<ShoulderSide, number>>>>(
"shoulderext",
projectId,
routeId,
) ?? {};
readState<Record<string, Partial<Record<ShoulderSide, number>>>>(name, projectId, routeId) ??
{};
all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) };
writeState("shoulderext", all, projectId, routeId);
writeState(name, all, projectId, routeId);
}
/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */
export const readShoulderExtends = (projectId: string | null, routeId: number | null) =>
readSides("shoulderext", projectId, routeId);
/** 한 측점 한 쪽 노견 확장을 담는다(0 = 되돌림 · 저장 때 정본의 옛 값을 지움). */
export const writeShoulderExtend = (
projectId: string,
routeId: number,
chainageM: number,
side: ShoulderSide,
valueM: number,
) => writeSide("shoulderext", projectId, routeId, chainageM, side, valueM);
/* ── 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · ≥ 0 · 194-1 · 횡단도 ▲▼) — 노견 확장과 같은 꼴.
* [저장]·[확정] 이 patch 로 싣고(서버가 다시 셈), 브라우저 재계산이 입력으로 씀. */
export const readBermDepths = (projectId: string | null, routeId: number | null) =>
readSides("bermdepth", projectId, routeId);
export const writeBermDepth = (
projectId: string,
routeId: number,
chainageM: number,
side: ShoulderSide,
depthM: number,
) => writeSide("bermdepth", projectId, routeId, chainageM, side, depthM);
+44 -6
View File
@@ -31,7 +31,11 @@ import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetc
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist";
import type { BermSpec } from "@util/common_util_cross_berm";
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
import {
crossDesignChoices,
readBermDepths,
readShoulderExtends,
} from "./B06_Section_Cross_Design_Session";
import {
effectiveStandardCross,
readRockBoundaryDefault,
@@ -95,6 +99,9 @@ export const USER_TOUCHED_KEYS = [
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
"shoulder_extend_left_m",
"shoulder_extend_right_m",
// 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력이라 재계산에도 넘김
"berm_depth_left_m",
"berm_depth_right_m",
] as const;
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
@@ -192,17 +199,39 @@ function bermPayload(
projectId: string,
routeId: number,
detail: SectionDetailResponse,
): Record<string, BermSessionSpec> {
): Record<string, BermSessionSpec & Partial<Record<`berm_depth_${Side}_m`, number>>> {
const spans = readBermSpans(projectId, routeId);
const out: Record<string, BermSessionSpec> = {};
const out: Record<string, BermSessionSpec & Partial<Record<`berm_depth_${Side}_m`, number>>> = {};
if (!spans.length) return out;
const depthAt = bermDepthReader(projectId, routeId);
for (const section of detail.cross_sections) {
const spec = bermSpecAt(spans, section.chainage_m);
if (spec) out[rockKey(section.chainage_m).toFixed(2)] = spec;
if (!spec) continue;
const design = (section.design ?? {}) as Record<string, unknown>;
const sides: Partial<Record<`berm_depth_${Side}_m`, number>> = {};
for (const side of SIDES) {
const own = depthAt(section.chainage_m, design, side);
if (own !== null) sides[`berm_depth_${side}_m`] = own;
}
out[rockKey(section.chainage_m).toFixed(2)] = { ...spec, ...sides };
}
return out;
}
type Side = "left" | "right";
const SIDES: Side[] = ["left", "right"];
/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */
function bermDepthReader(projectId: string, routeId: number) {
const session = readBermDepths(projectId, routeId);
return (chainageM: number, design: Record<string, unknown>, side: Side): number | null => {
const own = session.get(rockKey(chainageM))?.[side];
const stored = design[`berm_depth_${side}_m`];
const value = own ?? (typeof stored === "number" ? stored : null);
return value === null ? null : Math.max(value, 0);
};
}
/**
* 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다.
*
@@ -256,7 +285,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
// 순간 계단이 사라진다(계획서 3-9).
const bermSpans = readBermSpans(projectId, input.routeId);
const bermAt = (chainageM: number): BermSpec | null => {
const bermDepthAt = bermDepthReader(projectId, input.routeId);
const bermAt = (chainageM: number, design: Record<string, unknown>): BermSpec | null => {
const spec = bermSpecAt(bermSpans, chainageM);
return spec
? {
@@ -264,6 +294,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
intervalM: spec.interval_m,
slopeDeg: spec.slope_deg,
depthM: spec.depth_m,
// 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분
depthLeftM: bermDepthAt(chainageM, design, "left"),
depthRightM: bermDepthAt(chainageM, design, "right"),
}
: null;
};
@@ -325,7 +358,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
paved: pavedBySpecs(section),
standard,
berm: bermAt(section.chainage_m),
berm: bermAt(section.chainage_m, design),
rockBoundaryOffsetM,
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
twoStageSlope:
@@ -374,6 +407,11 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
);
section.design.shoulder_extend_left_m = extendLeft;
section.design.shoulder_extend_right_m = extendRight;
// 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1)
for (const side of SIDES) {
const own = next[`berm_depth_${side}_m`];
if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own;
}
updated.push(section.chainage_m);
}
return updated;
+4
View File
@@ -541,6 +541,10 @@ def compute_cross_design(
"slope_deg": round(float(berm.slope_deg), 4),
"depth_m": round(max(float(berm.depth_m), 0.0), 4),
}
# 쪽마다 옮긴 깊이(194-1) — 옮긴 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력)
for side, own in (("left", berm.depth_left_m), ("right", berm.depth_right_m)):
if own is not None:
result[f"berm_depth_{side}_m"] = round(max(float(own), 0.0), 4)
# 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력)
for side, extend in (
("left", geometry.shoulder_extend_left),
@@ -21,6 +21,7 @@ from common_util.common_util_cross_berm import (
BermSpec,
cut_profile_points,
fill_profile_points,
side_berm,
)
from common_util.common_util_cross_berm import elevation_at as berm_elevation_at
@@ -236,7 +237,9 @@ class _SectionGeometry:
edge_z = (
self.road_z(self.left_extent) if side == "left" else self.road_z(-self.right_extent)
)
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm, top_z=edge_z)
# 쪽마다 옮긴 깊이(194-1) — `side_berm`
berm = side_berm(self.berm, side)
points = fill_profile_points(start_dist, start_z, self.fill_ratio, berm, top_z=edge_z)
self._fill_points_cache[side] = points
return points
@@ -120,6 +120,70 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
return []
def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, float]]] | None:
"""구조물 정본의 소단 구간 `[(시작, 끝, 제원)]` — [저장] 서버 다시 셈의 소단 원천(194-1).
짝: TS `bermSpansFromStructures`(같은 규칙 — 폭 0 = 없음 · 깊이 빈 값 = 기본 1.0 · 0 미만 = 0).
못 읽으면 None(부르는 쪽이 저장분 `design.berm` 으로 물러섬).
"""
from common_util.common_util_cross_berm import (
BERM_DEFAULT_DEPTH_M,
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
)
def number(options: dict[str, Any], key: str, fallback: float) -> float:
try:
value = float(options.get(key))
except (TypeError, ValueError):
return fallback
return value if math.isfinite(value) and value >= 0 else fallback
try:
spans: list[tuple[float, float, dict[str, float]]] = []
for item in load_structures(str(project_root))[1]:
if item.type_id != BERM_TYPE_ID:
continue
anchor = item.chainage_m if item.chainage_m is not None else item.start_m
start = item.start_m if item.start_m is not None else anchor
end = item.end_m if item.end_m is not None else anchor
if start is None or end is None:
continue
options = item.options or {}
width = number(options, "width_m", BERM_DEFAULT_WIDTH_M)
if width <= 0:
continue
raw = options.get("depth_m")
try:
depth = BERM_DEFAULT_DEPTH_M if raw in (None, "") else max(float(raw), 0.0)
except (TypeError, ValueError):
depth = BERM_DEFAULT_DEPTH_M
if not math.isfinite(depth):
depth = BERM_DEFAULT_DEPTH_M
spec = {
"width_m": width,
"interval_m": number(options, "interval_m", BERM_DEFAULT_INTERVAL_M),
"slope_deg": number(options, "slope_deg", BERM_DEFAULT_SLOPE_DEG),
"depth_m": depth,
}
spans.append((float(min(start, end)), float(max(start, end)), spec))
return spans
except Exception: # noqa: BLE001 — 정본을 못 읽으면 저장분으로 물러섬
logger.exception("B06 소단 구간을 읽지 못했습니다 (저장분 소단을 씀)")
return None
def berm_span_at(
spans: list[tuple[float, float, dict[str, float]]], chainage_m: float
) -> dict[str, float] | None:
"""그 측점을 덮는 소단 제원 — 겹치면 먼저 놓은 것(TS `bermSpecAt` 짝)."""
for start, end, spec in spans:
if start - 1e-6 <= chainage_m <= end + 1e-6:
return spec
return None
def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
"""구간 안 측점의 횡단 dict에 `revetment` 키를 얹는다. 얹은 개수를 돌려준다.
+28 -10
View File
@@ -160,6 +160,9 @@ USER_TOUCHED_KEYS = (
# 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`)
"shoulder_extend_left_m",
"shoulder_extend_right_m",
# 소단 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼) — 기하 입력(`stored_berm`)
"berm_depth_left_m",
"berm_depth_right_m",
)
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
@@ -256,14 +259,20 @@ def enforce_design_lines(
받아 적었다(CLAUDE.md 5장 「[저장]은 서버가 정본으로 다시 계산」 어긋남 · B07 이 저장된 선을
그림). 입력 = 저장분(지반유형 · 단면유형 · 측구 · 포장 · 암 경계 · 암 절토 경사 · 소단 · 노견
확장) + 지금 종단 계획고 · 세월교 월류 하강 · 곡선부 확폭. 사용자 조작값은 승계.
소단 제원은 구조물 정본(`structures.json`)의 구간이 원천 — 쪽마다 옮긴 깊이는 측점 patch(194-1).
"""
from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans
ford_drops = ford_surface_drops(project_root)
spans = load_berm_spans(project_root)
changed = 0
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict):
continue
chainage = float(section.get("chainage_m", 0.0))
if spans is not None: # 구간을 지웠으면 소단 없음 · 못 읽으면 저장분 그대로
design = {**design, "berm": berm_span_at(spans, chainage)}
try:
recomputed = compute_cross_design(
section.get("samples", []),
@@ -449,22 +458,34 @@ def stored_cut_slope(stored: dict[str, Any]) -> float | None:
return None
def stored_berm(stored: dict[str, Any]) -> BermSpec | None:
"""저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC)."""
spec = stored.get("berm")
def berm_from(spec: Any, sides: dict[str, Any]) -> BermSpec | None:
"""소단 제원 dict + 쪽마다 옮긴 깊이(`berm_depth_{side}_m` · 194-1) → BermSpec."""
if not isinstance(spec, dict):
return None
def own(side: str) -> float | None:
value = sides.get(f"berm_depth_{side}_m")
ok = isinstance(value, (int, float)) and not isinstance(value, bool)
return max(float(value), 0.0) if ok else None
try:
return BermSpec(
width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)),
interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)),
slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)),
depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0),
depth_left_m=own("left"),
depth_right_m=own("right"),
)
except (TypeError, ValueError):
return None
def stored_berm(stored: dict[str, Any]) -> BermSpec | None:
"""저장분에 남은 소단 제원 + 쪽마다 옮긴 깊이 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC)."""
return berm_from(stored.get("berm"), stored)
def recompute_designs_for_alignment(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
@@ -500,13 +521,10 @@ def recompute_designs_for_alignment(
session_berms: dict[float, BermSpec] = {}
for raw_key, spec in (berms or {}).items():
try:
session_berms[round(float(raw_key), 3)] = BermSpec(
width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)),
interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)),
slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)),
depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0),
)
except (TypeError, ValueError, AttributeError):
parsed = berm_from(spec, spec if isinstance(spec, dict) else {}) # 쪽 깊이도(194-1)
if parsed is not None:
session_berms[round(float(raw_key), 3)] = parsed
except (TypeError, ValueError):
continue
for section in cross_sections:
chainage = float(section.get("chainage_m", 0.0))
+3
View File
@@ -184,6 +184,9 @@ class CrossSectionPatch(BaseModel):
# 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림(정본의 옛 값을 지움)
shoulder_extend_left_m: float | None = Field(default=None, ge=0, le=20)
shoulder_extend_right_m: float | None = Field(default=None, ge=0, le=20)
# 소단 쪽마다 옮긴 깊이(노면 끝 아래 m · 194-1 · 횡단도 ▲▼) — 0 = 노면 끝 높이(도로 위 금지)
berm_depth_left_m: float | None = Field(default=None, ge=0, le=50)
berm_depth_right_m: float | None = Field(default=None, ge=0, le=50)
inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None
basin_adjust: BasinAdjustPatch | None = None
# 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
@@ -116,6 +116,8 @@ export interface CrossDesignChange {
ditch_choice: boolean | null;
/** 설계선 쉬프트(139-6) — 있으면 그 쪽 노견 확장만 바꾸는 변경(다른 칸은 안 씀). */
shoulder_extend?: { side: "left" | "right"; valueM: number };
/** 소단 높이(194-1) — 있으면 그 쪽 소단 깊이(노면 끝 아래 m)만 바꾸는 변경. */
berm_depth?: { side: "left" | "right"; valueM: number };
}
/**
+165 -23
View File
@@ -10,11 +10,15 @@
* 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 ·
* 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 ·
* 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고).
* 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬.
* 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2).
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design";
import { clipSegment, type XY } from "../A00_Common/b_polygon_clip";
import { sideRole } from "@util/common_util_cross_design_geometry";
import { BERM_DEFAULT_DEPTH_M } from "@util/common_util_cross_berm";
import {
arrowsBelongToProfile,
bindArrowKeys,
@@ -61,7 +65,8 @@ export type CrossSide = "left" | "right";
type ZoneDesign = Pick<
CrossDesign,
"road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode"
>;
> &
Partial<Pick<CrossDesign, "berm" | "berm_depth_left_m" | "berm_depth_right_m">>;
/**
* offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 +
@@ -104,8 +109,46 @@ export const pieceZone = (design: ZoneDesign, piece: DesignPiece) =>
/** 고른 선이 움직이는 축(193-1) — 도로측(노면 · 노견) = 높이(y · 종단 계획고 ▲▼) ·
* 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */
export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): "x" | "y" =>
pieceZone(design, piece).zone === "road" ? "y" : "x";
export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis =>
isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x";
/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1). */
export type MoveAxis = "x" | "y" | "depth";
/** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */
export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number =>
design[`berm_depth_${side}_m`] ?? design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M;
/** 소단 수평 단인가 — 성토측 · 폭 = 소단 폭 · 안쪽 끝 표고 = 노면 끝 − 깊이(194-1). */
export function isBermPiece(design: ZoneDesign, piece: DesignPiece): boolean {
const berm = design.berm;
if (!berm || piece.structure) return false;
const { zone, side } = pieceZone(design, piece);
const edge = design.road_edges?.[side];
if (zone !== "fill" || !edge) return false;
const rise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m;
const low = Math.min(piece.from.elevation, piece.to.elevation);
const high = Math.max(piece.from.elevation, piece.to.elevation);
return (
Math.abs(Math.abs(piece.to.offset - piece.from.offset) - berm.width_m) < 0.05 &&
Math.abs(low - (edge.elevation_m - bermDepthOf(design, side))) < 0.02 &&
high - low <= rise + 0.01
);
}
/** 조각 키 = 갈래 + 쪽 + 차례(가운데에서 바깥으로) — 다시 그려도 안 바뀜(194-2). */
export function pieceKeys(design: ZoneDesign, pieces: DesignPiece[]): string[] {
const mid = (p: DesignPiece) => Math.abs(p.from.offset + p.to.offset) / 2;
const kinds = pieces.map((p) => {
const { zone, side } = pieceZone(design, p);
return `${isBermPiece(design, p) ? "berm" : zone}${p.structure ? "+s" : ""}:${side}`;
});
return pieces.map((p, i) => {
const rank = pieces.filter(
(q, j) => kinds[j] === kinds[i] && (mid(q) < mid(p) || (mid(q) === mid(p) && j < i)),
).length;
return `${kinds[i]}:${rank}`;
});
}
const gap = (a: CrossPoint, b: CrossPoint): number =>
Math.hypot(a.offset - b.offset, a.elevation - b.elevation);
@@ -201,7 +244,7 @@ export function visiblePieces(pieces: DesignPiece[], occluders: CrossPoint[][]):
return out;
}
/** 치수 — 사면길이 · 수평 · 높이(m) · 구배(사면 1:n · 노면 %). */
/** 치수 — 길이 · 수평 · 높이(m) · 경사비율(사면 1:n · 노면 %). */
export function pieceMeasure(
piece: DesignPiece,
zone: CrossZone,
@@ -219,23 +262,32 @@ export function pieceMeasure(
return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade };
}
/** 두 줄(151-1) — 1줄 = 길이 · 수평 · 2줄 = 높이 · 구배(줄바꿈 글자로 가름). */
/** 두 줄(151-1 · 195-1) — 1줄 = 길이 · 경사비율 · 2줄 = 수평 · 높이(줄바꿈 글자로 가름). */
export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string {
const { zone } = pieceZone(design, piece);
const m = pieceMeasure(piece, zone);
return (
`${zone === "road" ? "길이" : "사면길이"} ${m.lengthM.toFixed(2)}m · ` +
`수평 ${m.horizontalM.toFixed(2)}m\n높이 ${m.heightM.toFixed(2)}m · ${m.grade}`
`길이 ${m.lengthM.toFixed(2)}m · 경사비율 ${m.grade}\n` +
`수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m`
);
}
/* ── 페이지 한 칸 고름 — 측점 + 조각 번호(카드를 다시 그려도 되살림 · 137-3) ───────── */
let picked: { stationId: string; index: number } | null = null;
export const pickedLineAt = (stationId: string): number | null =>
picked?.stationId === stationId ? picked.index : null;
/* ── 페이지 한 칸 고름 — 측점 + 조각 키(카드를 다시 그려도 되살림 · 137-3) ───────────
* 번호가 아니라 키(`pieceKeys`)로 기억 — 옮겨 다시 그리면 조각 차례가 바뀌어도 같은 조각(194-2).
* 키를 안 적은 측점(카드 밖 시험)은 번호 그대로. */
let picked: { stationId: string; key: string } | null = null;
const keysByStation = new Map<string, string[]>();
export function pickedLineAt(stationId: string): number | null {
if (picked?.stationId !== stationId) return null;
const keys = keysByStation.get(stationId);
const index = keys ? keys.indexOf(picked.key) : Number(picked.key);
return index >= 0 ? index : null;
}
export function setPickedLine(stationId: string, index: number | null): void {
if (index !== null) picked = { stationId, index };
else if (picked?.stationId === stationId) picked = null;
if (index !== null) {
const keys = keysByStation.get(stationId);
picked = { stationId, key: keys ? (keys[index] ?? "") : String(index) };
} else if (picked?.stationId === stationId) picked = null;
}
/* ── 카드 배선 ─────────────────────────────────────────────────────────── */
@@ -270,6 +322,54 @@ export function shiftedExtend(now: number, side: CrossSide, screenDir: -1 | 1, k
return Math.max(0, Math.round((now + outward) * 10) / 10);
}
/** 소단 높이(194-1) — 쪽마다 깊이(노면 끝 아래 m · ≥ 0) · 고름 켬/끔을 좌측 소단 상자에 알림. */
export interface BermDepthControl {
set: (side: CrossSide, depthM: number) => void;
focus: (on: boolean) => void;
}
/** 소단 고름 → 좌측 패널 소단 상자 켬(194-2) — 페이지가 단다(측점 · null = 끔). */
let bermHost: ((chainageM: number | null) => void) | null = null;
let bermHostAt: number | null = null;
export function setBermPickHost(host: ((chainageM: number | null) => void) | null): void {
bermHost = host;
bermHostAt = null;
}
function tellBermHost(chainageM: number | null): void {
if (chainageM === bermHostAt) return; // 다시 그린 카드의 되살림은 한 번만
bermHostAt = chainageM;
bermHost?.(chainageM);
}
/** 카드 → 고름 이동 창구 — 노견 확장(139-6) · 소단 깊이(194-1) 둘 다 카드 버튼과 같은 창구. */
export function designLineMoves(
section: Pick<CrossSection, "station_id" | "chainage_m" | "design">,
onDesignChange?: (chainageM: number, change: CrossDesignChange) => void,
): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl } {
const design = section.design;
if (!onDesignChange || !design) return { stationId: section.station_id };
const send = (change: Partial<CrossDesignChange>) =>
onDesignChange(section.chainage_m, {
...(design as unknown as CrossDesignChange),
...change,
});
return {
stationId: section.station_id,
shift: {
valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0,
set: (side, valueM) => send({ shoulder_extend: { side, valueM } }),
},
berm: {
set: (side, valueM) => send({ berm_depth: { side, valueM } }),
// 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움
focus: (on) => {
if (on) tellBermHost(section.chainage_m);
else if (bermHostAt === section.chainage_m) tellBermHost(null);
},
},
};
}
export interface DesignLinePick {
pieces: DesignPiece[];
/** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */
@@ -291,6 +391,10 @@ export function attachDesignLinePick(input: {
onPick: (index: number) => void;
/** 있으면 고른 조각 쪽 사면을 이동 패널로 옮김(139-6 · 151-3). */
shift?: ShoulderShift;
/** 있으면 고른 소단을 ▲▼ 로 옮김(194-1). */
berm?: BermDepthControl;
/** 있으면 고름을 조각 키로 기억(194-2 · `pickedLineAt`). */
stationId?: string;
}): DesignLinePick {
const { layer, design, x, toDisplayY } = input;
const x0 = x(0);
@@ -313,6 +417,7 @@ export function attachDesignLinePick(input: {
)
.map((el) => pointsOf(el).map(toM));
const pieces = visiblePieces(mergeStraightPieces(runs), occluders);
if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces));
// 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김.
const firstStructureHit = kids.find((el) =>
@@ -337,9 +442,9 @@ export function attachDesignLinePick(input: {
label.className = "b06-cross-card__line-dims";
label.hidden = true;
let side: CrossSide | null = null;
let axis: "x" | "y" = "x";
let axis: MoveAxis = "x";
let active: number | null = null;
const { shift } = input;
const { shift, berm } = input;
// 사면 이동 패널(151-3) — B06 공용 조정창 꼴(머리 · 「이동」 줄 십자 · 방향키 ←→).
// ✕ = 같은 조각 다시 고름(카드가 풂) · ↺ = 그 쪽 0.
@@ -370,8 +475,32 @@ export function attachDesignLinePick(input: {
step(1),
),
);
shell.root.append(readout, move.row);
const arrows = bindArrowKeys(shell.root, () => [move.controls]);
// 소단 높이 줄(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤)
const lift = makeRow("높이");
lift.controls.classList.add("b06-structure-panel__buttons");
const toeZ = (at: CrossSide): number =>
Math.min(
...pieces
.filter((p) => !p.structure && pieceZone(design, p).side === at)
.flatMap((p) => [p.from.elevation, p.to.elevation]),
);
const deepen = (dir: -1 | 1) => () => {
if (!side || !berm) return;
const now = bermDepthOf(design, side);
const next = Math.max(0, Math.round((now + dir * SHIFT_STEP_M) * 10) / 10);
const edgeZ = design.road_edges?.[side]?.elevation_m;
if (dir > 0 && edgeZ !== undefined && edgeZ - next < toeZ(side) + SHIFT_STEP_M) return;
if (next !== now) berm.set(side, next);
};
lift.controls.append(
dpadButton("▲", "up", "고른 소단을 0.1 m 위로(노면 끝보다 높을 수 없음)", deepen(-1)),
dpadButton("↺", "reset", "이 쪽 소단 = 구간 기본 깊이", () => {
if (side && berm) berm.set(side, design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M);
}),
dpadButton("▼", "down", "고른 소단을 0.1 m 아래로", deepen(1)),
);
shell.root.append(readout, move.row, lift.row);
const arrows = bindArrowKeys(shell.root, () => [move.controls, lift.controls]);
// 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감.
// 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김.
const blockOffAxis = (event: KeyboardEvent): void => {
@@ -402,14 +531,27 @@ export function attachDesignLinePick(input: {
label.textContent = piece
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
: "";
const open = !!(piece && shift);
const open = !!(piece && (axis === "depth" ? berm : shift));
shell.root.classList.toggle("is-hidden", !open);
move.row.classList.toggle("is-hidden", axis === "y");
move.controls.classList.toggle("is-hidden", axis === "y");
// 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단)
move.row.classList.toggle("is-hidden", axis !== "x");
move.controls.classList.toggle("is-hidden", axis !== "x");
lift.row.classList.toggle("is-hidden", axis !== "depth");
lift.controls.classList.toggle("is-hidden", axis !== "depth");
const sideName = side === "left" ? "(좌)" : "(우)";
shell.title.textContent =
axis === "y" ? "노면 높이" : side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)";
axis === "y"
? "노면 높이"
: axis === "depth"
? `소단 높이 ${sideName}`
: `사면 이동 ${sideName}`;
readout.textContent =
axis === "y" ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" : `이동 ${extend.toFixed(1)}m`;
axis === "y"
? "방향키 ▲▼ = 종단 계획고(종단도 함께)"
: axis === "depth" && side
? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m`
: `이동 ${extend.toFixed(1)}m`;
berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2)
arrows.detach();
document.removeEventListener("keydown", blockOffAxis);
if (open) {
@@ -6,6 +6,7 @@
* ⭐ 2026-09-15 — 그 밑에 사면길이 3m 초과 **소단 검토 정보 한 줄**(경고 모양 아님 · 브레인 ③).
* ⭐ 138-1 — 「⚠ 포장 권장 · n측점」 줄(B05 법정 경사 제안 · 포장 범위 밖 측점 · 옛 카드 배지 자리).
* ⭐ 138-3 — 줄 전체가 카드 칸 위에 고정(sticky) · 알림 없어도 줄 유지(오른쪽 끝 열 수 칸은 부르는 쪽이 붙임).
* ⭐ 196-1 — 항목 = 짧은 이름 + 수 칩 · 긴 설명 = 호버 · 내용 칸 하나 공유(`_Notice_Chips`).
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
@@ -18,83 +19,65 @@ import {
} from "./B06_Section_UI_Cross_FillSlope_Warn";
import { L, stationLabel } from "./B06_Section_UI_Section_Common";
import { pavedBySpecs } from "./B06_Section_Cross_Refresh";
import { createNoticeChips, type NoticeChipRow } from "./B06_Section_UI_Cross_Notice_Chips";
export interface FillSlopeNotice {
root: HTMLElement;
/** 측점 설계가 바뀔 때마다 부른다 — 펼침 상태는 그대로 둔다. */
/** 측점 설계가 바뀔 때마다 부른다 — 연 칩은 그대로 둔다. */
update: (sections: ReadonlyArray<CrossSection>, stationInterval: number) => void;
}
function noticeBlock(className: string): {
root: HTMLDetailsElement;
fill: (head: string, rows: FillSlopeWarning[], stationInterval: number) => void;
} {
const root = document.createElement("details");
root.className = className;
root.hidden = true;
const summary = document.createElement("summary");
const list = document.createElement("div");
list.className = "b06-section__notice-list";
root.append(summary, list);
return {
root,
fill(head, rows, stationInterval) {
root.hidden = !rows.length;
summary.textContent = `${head} · ${rows.length}측점`;
list.replaceChildren(
...rows.map(({ section, sides }) => {
const button = document.createElement("button");
button.type = "button";
const parts = sides.map(({ side, lengthM, open }) => {
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`;
});
button.textContent =
`${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim();
button.dataset.stationId = section.station_id;
return button;
}),
);
},
};
/** 측점 단추 글 — 측점 + 쪽별 길이(포장 권장은 측점만) */
function rows(warnings: FillSlopeWarning[], stationInterval: number): NoticeChipRow[] {
return warnings.map(({ section, sides }) => {
const parts = sides.map(({ side, lengthM, open }) => {
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`;
});
return {
stationId: section.station_id,
text: `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`.trim(),
};
});
}
export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice {
const root = document.createElement("div");
root.className = "b06-section__notice-bar";
const blocks = document.createElement("div");
blocks.className = "b06-section__notice-blocks";
root.append(blocks);
const warn = noticeBlock("b06-section__notice");
const info = noticeBlock("b06-section__notice b06-section__notice--info");
const pave = noticeBlock("b06-section__notice");
pave.root.title = L("B06_Design_Paved_Suggested");
blocks.append(warn.root, pave.root, info.root);
root.addEventListener("click", (event) => {
const id = (event.target as HTMLElement).closest("button")?.dataset.stationId;
if (id) onPick(id);
});
const chips = createNoticeChips(onPick);
root.append(chips.root);
return {
root,
update(sections, stationInterval) {
warn.fill(
`⚠ ${FILL_SLOPE_WARN_TEXT}`,
fillSlopeWarnings(sections, fillSlopeLengths),
stationInterval,
);
pave.fill(
`⚠ ${L("B06_Notice_PaveSuggested")}`,
sections
.filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section))
.map((section) => ({ section, sides: [] })),
stationInterval,
);
info.fill(
`ⓘ ${BERM_INFO_TEXT}`,
bermReviewStations(sections, slopeRunLengths),
stationInterval,
);
chips.update([
{
key: "fill",
label: L("B06_Notice_FillSlope"),
hint: FILL_SLOPE_WARN_TEXT,
tone: "warn",
rows: rows(fillSlopeWarnings(sections, fillSlopeLengths), stationInterval),
},
{
key: "pave",
label: L("B06_Notice_PaveSuggested"),
hint: L("B06_Design_Paved_Suggested"),
tone: "warn",
rows: rows(
sections
.filter((section) => section.design?.pavement_suggested && !pavedBySpecs(section))
.map((section) => ({ section, sides: [] })),
stationInterval,
),
},
{
key: "berm",
label: L("B06_Notice_Berm"),
hint: BERM_INFO_TEXT,
tone: "info",
rows: rows(bermReviewStations(sections, slopeRunLengths), stationInterval),
},
]);
},
};
}
@@ -0,0 +1,95 @@
/* =============================================================================
* B06_Section_UI_Cross_Notice_Chips.ts
* 횡단 알림 줄 칩 꼴(196-1) — 항목 = 짧은 이름 + 측점 수 칩 한 줄 · 긴 설명 = 공용 호버.
* 줄 아래 내용 칸 하나를 같이 씀 — 누른 칩의 측점 목록만 · 같은 칩 다시 누르면 닫힘 ·
* 다른 칩 누르면 바뀜(한 번에 하나). 측점 단추 누름 = `onPick`(그 카드로).
* 무엇을 셀지는 부르는 쪽(`_FillSlope_Notice`) — 여기는 그리기만.
* ========================================================================== */
import { attachHoverInfo } from "@ui/ui_template_hover_info";
export interface NoticeChipRow {
stationId: string;
text: string;
}
export interface NoticeChipItem {
key: string;
/** 짧은 이름 — 칩 글 = 이름 + 측점 수 */
label: string;
/** 긴 설명 — 호버 */
hint: string;
tone: "warn" | "info";
rows: NoticeChipRow[];
}
export interface NoticeChips {
root: HTMLElement;
/** 측점 설계가 바뀔 때마다 — 연 칩은 그대로(측점이 0 이 되면 닫힘) */
update: (items: NoticeChipItem[]) => void;
}
export function createNoticeChips(onPick: (stationId: string) => void): NoticeChips {
const root = document.createElement("div");
root.className = "b06-section__notice-blocks";
const chips = document.createElement("div");
chips.className = "b06-section__notice-chips";
const panel = document.createElement("div");
panel.className = "b06-section__notice-list";
panel.hidden = true;
root.append(chips, panel);
let items: NoticeChipItem[] = [];
let openKey: string | null = null;
const render = (): void => {
const open = items.find((item) => item.key === openKey);
if (!open) openKey = null;
chips.replaceChildren(
...items.map((item) => {
const chip = document.createElement("button");
chip.type = "button";
chip.className = `b06-section__notice-chip b06-section__notice-chip--${item.tone}`;
chip.dataset.noticeKey = item.key;
chip.setAttribute("aria-expanded", String(item === open));
chip.textContent = `${item.label} ${item.rows.length}`;
return chip;
}),
);
panel.hidden = !open;
panel.classList.toggle("is-info", open?.tone === "info");
panel.replaceChildren(
...(open?.rows ?? []).map((row) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = row.text;
button.dataset.stationId = row.stationId;
return button;
}),
);
};
chips.addEventListener("click", (event) => {
const key = (event.target as HTMLElement).closest<HTMLElement>(".b06-section__notice-chip")
?.dataset.noticeKey;
if (!key) return;
openKey = openKey === key ? null : key;
render();
});
panel.addEventListener("click", (event) => {
const id = (event.target as HTMLElement).closest("button")?.dataset.stationId;
if (id) onPick(id);
});
attachHoverInfo(
chips,
(target) => items.find((item) => item.key === target.dataset.noticeKey)?.hint,
".b06-section__notice-chip",
);
return {
root,
update(next) {
items = next.filter((item) => item.rows.length);
render();
},
};
}
+3 -12
View File
@@ -29,7 +29,6 @@ import {
appendCrossDesignOverlay,
appendPavementOverlay,
appendRockBoundaryOverlay,
type CrossDesignChange,
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome";
@@ -59,6 +58,7 @@ import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure";
import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels";
import {
attachDesignLinePick,
designLineMoves,
DESIGN_LINE_ATTR,
type DesignLinePick,
pickedLineAt,
@@ -593,17 +593,8 @@ export function createCrossSectionCard(
x,
toDisplayY,
onPick: pickLine,
// 고른 선 쪽 노견 확장(139-6) — 카드 버튼과 같은 창구(세션 · 브라우저 재계산)
shift: onDesignChange
? {
valueOf: (side) => lineDesign[`shoulder_extend_${side}_m`] ?? 0,
set: (side, valueM) =>
onDesignChange(section.chainage_m, {
...(lineDesign as unknown as CrossDesignChange),
shoulder_extend: { side, valueM },
}),
}
: undefined,
// 고른 선 쪽 노견 확장(139-6) · 소단 높이(194-1) · 조각 키(194-2) — 카드 버튼과 같은 창구
...designLineMoves(section, onDesignChange),
});
setLineActive = linePick.setActive;
setLineActive(pickedLineAt(section.station_id));
@@ -14,6 +14,7 @@
import { API_BASE_URL } from "@config/config_frontend";
import { createButton, showToast } from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
interface MissingStation {
chainage_m: number;
@@ -67,8 +68,9 @@ export async function mountMissingStationNotice(
.join(" · ");
text.textContent =
`측점이 없는 구조물 ${missing.length}개 — ${where}` +
(missing.length > 6 ? ` 외 ${missing.length - 6}개` : "") +
". 이 자리는 횡단도에도 안 서고 수량에서도 빠집니다.";
(missing.length > 6 ? ` 외 ${missing.length - 6}개` : "");
// 긴 까닭 글 = 호버(196-1)
attachHoverInfo(text, "이 자리는 횡단도에도 안 서고 수량에서도 빠집니다.");
box.append(text);
if (data.can_create) {
+13 -1
View File
@@ -9,7 +9,11 @@
* - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영]
* ========================================================================== */
import { writeCrossDesignChoice, writeShoulderExtend } from "./B06_Section_Cross_Design_Session";
import {
writeBermDepth,
writeCrossDesignChoice,
writeShoulderExtend,
} from "./B06_Section_Cross_Design_Session";
import { showToast } from "@ui/ui_template_elements";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import {
@@ -74,6 +78,14 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
await reconcileStaleDesigns({ force: true });
return;
}
// 소단 높이(194-1) — 그 쪽 깊이만 세션에 · 캐시에 바로 · 브라우저 재계산(같은 창구)
const berm = change.berm_depth;
if (berm) {
writeBermDepth(projectId, currentRouteId, chainageM, berm.side, berm.valueM);
if (target.design) target.design[`berm_depth_${berm.side}_m`] = berm.valueM;
await reconcileStaleDesigns({ force: true });
return;
}
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
if (target.design) {
+11 -1
View File
@@ -29,7 +29,11 @@ import {
STRUCTURE_ROW_KEYS,
structureAreaRows,
} from "./B06_Section_Structure_Layouts";
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
import {
crossDesignChoices,
readBermDepths,
readShoulderExtends,
} from "./B06_Section_Cross_Design_Session";
import { readStructureNotes } from "./B06_Section_UI_Cross_Detail";
import { readDetailTables } from "./B06_Section_UI_Cross_Detail_Table";
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
@@ -420,6 +424,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
if (sides.left !== undefined) patch.shoulder_extend_left_m = sides.left;
if (sides.right !== undefined) patch.shoulder_extend_right_m = sides.right;
});
// 소단 쪽마다 옮긴 깊이(194-1) — 서버가 [저장] 때 구조물 정본의 소단 구간 + 이 값으로 다시 냄
readBermDepths(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => {
const patch = patchFor(chainage);
if (sides.left !== undefined) patch.berm_depth_left_m = sides.left;
if (sides.right !== undefined) patch.berm_depth_right_m = sides.right;
});
if (detail) {
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
@@ -9,6 +9,8 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
import type { RevetPick } from "./B06_Section_UI_Cross_Culvert";
import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel";
import { setBermPickHost } from "./B06_Section_UI_Cross_Design_Pick";
import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist";
/**
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그
@@ -164,6 +166,28 @@ export function wireStructureSelection(
pickBox(born);
});
// 횡단도 소단 수평 단 고름 → 좌측 폼에 그 소단 · 「상세 내용」 상자 켬(194-2 · 191 상자 고름 짝)
const BERM_BOX = "fieldset.b05-structure__optgroup";
setBermPickHost((chainageM) => {
panel.root
.querySelectorAll<HTMLElement>(`${BERM_BOX}.is-active`)
.forEach((box) => box.classList.remove("is-active"));
if (chainageM === null) return;
const berm = panel.getStructures().find((item) => {
if (item.type_id !== BERM_TYPE_ID) return false;
const start = item.start_m ?? item.chainage_m ?? -1;
const end = item.end_m ?? item.chainage_m ?? -1;
return chainageM >= Math.min(start, end) - 1e-6 && chainageM <= Math.max(start, end) + 1e-6;
});
if (!berm?.structure_id) return;
panel.selectStructure(berm.structure_id);
const box = [...panel.root.querySelectorAll<HTMLElement>(BERM_BOX)].find(
(el) => !el.closest("[hidden]"),
);
box?.classList.add("is-active");
box?.scrollIntoView({ block: "nearest" });
});
const origFord = stationControls.ford.select;
stationControls.ford.select = (chainageM, role) => {
const nested = wallSelecting;
+30 -19
View File
@@ -142,10 +142,6 @@
min-width: 0;
}
.b06-section__notice-blocks > .b06-section__notice:last-of-type {
margin-bottom: 0;
}
/* 열 수 칸 — 라벨 왼쪽 · 한 줄(150-1) */
.ui-field.b06-section__columns {
flex: none;
@@ -168,33 +164,48 @@
display: none;
}
/* 펼친 목록이 길면 줄 안에서 스크롤 — 고정 줄이 카드 칸을 다 덮지 않게 */
.b06-section__notice-list {
max-height: 30vh;
overflow: auto;
/* 알림 칩 한 줄(196-1) — 짧은 이름 + 수 · 긴 설명 = 호버 · 누른 칩 = 눌림 꼴 */
.b06-section__notice-chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
/* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */
.b06-section__notice {
margin-bottom: var(--spacing-8);
.b06-section__notice-chip {
padding: 0 var(--spacing-8);
border: 1px solid currentColor;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-warning);
font-size: var(--text-caption);
}
/* 소단 검토 — 정보 한 줄(경고 색 아님 · 2026-09-15 브레인 ③). */
.b06-section__notice--info {
color: var(--color-text-secondary);
}
.b06-section__notice > summary {
white-space: nowrap;
cursor: pointer;
}
/* 소단 검토 — 정보(경고 색 아님 · 2026-09-15 브레인 ③). */
.b06-section__notice-chip--info,
.b06-section__notice-list.is-info {
color: var(--color-text-secondary);
}
.b06-section__notice-chip[aria-expanded="true"] {
background: var(--color-surface-raised);
font-weight: 600;
}
/* 내용 칸 하나(누른 칩의 측점 목록) — 길면 줄 안에서 스크롤 · 고정 줄이 카드 칸을 다 덮지 않게 */
.b06-section__notice-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
max-height: 30vh;
overflow: auto;
padding-top: var(--spacing-4);
color: var(--color-warning);
}
.b06-section__notice-list[hidden] {
display: none;
}
.b06-section__notice-list > button {
@@ -223,7 +223,7 @@
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */
/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 경사비율 / 수평 · 높이). */
.b06-cross-card__line-dims {
align-self: center;
padding: 1px var(--spacing-4);
+1 -1
View File
@@ -595,7 +595,7 @@ def pipe_summary(
notes = []
missing = sheet.unlinked | {key for key, found in others.items() if not found}
if missing:
notes.append(f"조합 사본 없는 시설 {len(missing)}곳 — 「미연결」 줄을 눌러 조합 연결")
notes.append(f"조합 사본 없는 시설 {len(missing)}곳 — 노랑 램프 줄을 눌러 조합 연결")
if no_length:
notes.append(f"관 연장 없음 {no_length}곳 — B06 횡단 [저장] 뒤 채워짐")
if no_basin:
+3 -14
View File
@@ -5,8 +5,8 @@
* 줄 = 구조물 · 배관이 놓인 측점(같은 측점은 한 줄 · 오름차순)
* 열 = 저장된 구조물 조합(본체 `summary_item` + 그룹 `group_items`) — 같은 층 · 주인 · 키는 한 열
* 조합 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · 서버 `linked_snapshots` · PLAN 143-1)
* 미연결 시설 = 서버 `unlinked`(셈한 칸 기준) — 그 측점도 줄(값 빈칸 · 「미연결」 · PLAN 154-1)
* 「미지정」 열 글 · 같은 조합 열 칸 = 빈 조합 칸(`unassignedText` · `unassignedCombos` · 160-2)
* 미연결 시설 = 서버 `unlinked`(셈한 칸 기준) — 그 측점도 줄(값 빈칸 · 노랑 램프 · PLAN 154-1 · 198-1)
* 램프 호버 글 = 빈 조합 칸(`unassignedText` · 160-2)
*
* 읽기만 — B05/B06 저장 자료(structures.json · pipe_points.json)를 고치지 않는다.
* 런타임 import 는 `common_util_summary_item` 하나 — 시험이 두 파일을 이어 붙여 돌림.
@@ -114,7 +114,7 @@ export interface ComboColumn {
export const comboId = (item: SummaryItem): string =>
`${item.layer}|${item.owner ?? ""}|${item.key}`;
/** 측점 「미지정」 칸 글 — 시설마다 「이름: 빈 칸 · 빈 칸」(칸 이름 = 그룹 · 본체 = `body` · 160-2). */
/** 측점 램프 호버 글 —시설마다 「이름: 빈 칸 · 빈 칸」(칸 이름 = 그룹 · 본체 = `body` · 160-2). */
export const unassignedText = (facilities: readonly UnlinkedFacility[], body: string): string =>
facilities
.map((f) => {
@@ -123,17 +123,6 @@ export const unassignedText = (facilities: readonly UnlinkedFacility[], body: st
})
.join(" / ");
/** 빈 칸에 고른 조합 id — 같은 조합 열이 있으면 그 칸에 「미지정」(160-2). */
export function unassignedCombos(facilities: readonly UnlinkedFacility[]): Set<string> {
const ids = new Set<string>();
for (const f of facilities)
for (const slot of f.slots) {
const item = slot.linked ? null : readSummaryItem(slot.item);
if (item) ids.add(comboId(item));
}
return ids;
}
function stationOf(thing: PlacedItem): number | null {
const value = thing.chainage_m ?? thing.start_m;
return typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -234,6 +234,7 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number)
frozen: 1,
total: totalRow(table),
caption: "토적표",
fillViewport: true,
});
grid.root.dataset.b08Earthwork = "table";
host.replaceChildren(tableCard(info, grid.root));
@@ -41,6 +41,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable)
})),
frozen: 1,
caption: "토공집계표",
fillViewport: true,
});
host.replaceChildren(tableCard([info], grid.root));
}
@@ -91,7 +92,14 @@ function haulSheet(
`${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`,
),
);
const grid = createDataTable({ columns, rows, frozen: 1, total, caption: info });
const grid = createDataTable({
columns,
rows,
frozen: 1,
total,
caption: info,
fillViewport: true,
});
host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages));
}
+1
View File
@@ -366,6 +366,7 @@ export async function renderMaterialSheet(
frozen: built.frozen,
empty: "",
caption: BASIS,
fillViewport: true,
editable: (i, key) => built.editable(i, key),
onEdit: (i, key, text) => {
const value = parse(text);
+12
View File
@@ -44,6 +44,18 @@
overflow-wrap: anywhere;
}
/* 구조물 집계표 미연결 램프(198-1) — 측점 칸 왼쪽 경고 노랑 점 하나(177-1 미연결 노랑 토큰) · 연결 줄은 없음 */
[data-b08-summary="table"] td.b08-summary__lamp::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
margin-right: 6px;
border-radius: 50%;
background: var(--color-warning);
vertical-align: middle;
}
/* 좌측 패널 옵션(122) — 연암 · 경암 두 칸 나란히 · 설명 한 줄 */
.b08-options__row {
display: grid;
+24 -27
View File
@@ -6,8 +6,8 @@
* 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만)
* 머리 두 줄 = 조합 구분(경로 첫 칸) · 조합 이름(나머지 경로) — 사본 manifest 경로(집계 응답 `linked`)
* 조합 · 측점 = 프로젝트 안 사본이 있는 것만(`linkedOnly` · PLAN 143-1) — 미연결 시설은 그 측점
* 줄만 「· 미연결」(값 빈칸 · 서버 `unlinked` · PLAN 154-1) · 측점 옆 「미지정」 열 = 그 측점 빈 조합 칸
* · 같은 조합 열이 있으면 그 칸에도 「미지정」(160-2 · 화면만)
* 줄만 측점 칸 왼쪽 노랑 램프(값 빈칸 · 서버 `unlinked` · PLAN 154-1 · 198-1)
* · 램프 호버(공용 `ui_template_hover_info`) = 그 측점 빈 조합 칸(160-2 · 화면만)
* 횡단배수 열 · 칸 = GET …/quantity/summary(서버 단독 · PLAN 98 · 120) — 머리 구조물 · 형식 · 규격
* 3단 + 단위 줄 · 「관 공」 맨 윗 머리 = 관에 이은 조합 경로(있으면) · 서버가 펼친 종류(`types`)는
* 조합 열에서 뺌 · 다른 구조물 열은 조합 그대로
@@ -24,6 +24,7 @@ import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { pipeStructureId } from "@ui/ui_template_item_picker_project";
import { stationLabel } from "../common_util/common_util_station";
import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item";
@@ -39,7 +40,6 @@ import {
type LinkedSnapshot,
type PlacedItem,
type UnlinkedFacility,
unassignedCombos,
unassignedText,
unlinkedAt,
widthOf,
@@ -98,6 +98,9 @@ async function fetchPipeSummary(projectId: string): Promise<PipeSummary> {
}
const PIPE_TOP = "관 공";
/** 미연결 램프 칸 class(`B08_Quantity_UI_Page.css` · 198-1) · 램프 자리 폭(12px 글 기준) */
const LAMP_CLASS = "b08-summary__lamp";
const LAMP_W = 14;
const stationKey = (station: number): number => Math.round(station * 100);
/** 열 소수 자리 — 칸 · 계 값이 쓰는 만큼(최대 2). */
const placesOf = (values: number[]): number =>
@@ -150,29 +153,14 @@ function buildTable(
? { station: L("B08_Quantity_Summary_Total"), ...pipe.total }
: null;
const labels = stations.map((station) => stationLabel(station, interval));
// 미연결 시설 측점 = 「· 미연결」(값 빈칸 · 누르면 바로 연결 · 154)
const shown = stations.map((station, i) =>
unlinkedAt(unlinked, station).length
? `${labels[i]} · ${L("B08_Quantity_Summary_Unlinked")}`
: labels[i],
);
const stationHead = L("B08_Quantity_Summary_Station");
// 160-2 — 빈 조합 칸 이름 · 같은 조합 열 칸에도 「미지정」(미연결 시설이 있을 때만 열)
const missingHead = L("B08_Quantity_Summary_Unassigned");
const here = stations.map((station) => unlinkedAt(unlinked, station));
const missing = here.map((at) => unassignedText(at, L("B08_Quantity_Link_Body")));
const hasMissing = missing.some(Boolean);
const comboIds = new Set(columns.map((column) => column.id));
const markOf = (at: readonly UnlinkedFacility[]): Record<string, string> =>
Object.fromEntries(
[...unassignedCombos(at)].filter((id) => comboIds.has(id)).map((id) => [id, missingHead]),
);
// 198-1 — 미연결 시설 측점 = 측점 칸 왼쪽 노랑 램프(값 빈칸 · 누르면 바로 연결 · 154) · 호버 = 빈 조합 칸(160-2)
const missing = stations.map((station) =>
unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body")),
);
const table = createDataTable({
columns: [
{ key: "station", label: stationHead, width: widthOf(stationHead, ...shown) },
...(hasMissing
? [{ key: "unassigned", label: missingHead, width: widthOf(missingHead, ...missing) }]
: []),
{ key: "station", label: stationHead, width: widthOf(stationHead, ...labels) + LAMP_W },
...columns.map((column) => ({
key: column.id,
label: column.name,
@@ -185,18 +173,27 @@ function buildTable(
],
// 조합 열 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 · 배관 칸 = 서버 값
rows: stations.map((station, i) => ({
station: shown[i],
...(hasMissing ? { unassigned: missing[i] } : {}),
...markOf(here[i]),
station: labels[i],
...values.get(stationKey(station)),
})),
total,
onRowClick: (i) => onRowClick(stations[i], labels[i]),
frozen: hasMissing ? 2 : 1,
cellClass: (i, key) => (key === "station" && missing[i] ? LAMP_CLASS : null),
frozen: 1,
empty: "",
caption: L("B08_Quantity_Tab_StructureSummary"),
fillViewport: true,
});
table.root.dataset.b08Summary = "table";
// 줄을 다시 그려도(보이는 줄만) 떠 있는 root 하나에 걸어 둠 — 줄 번호 = 줄 `data-i`
attachHoverInfo(
table.root,
(cell) => {
const why = missing[Number(cell.closest("tr")?.dataset.i)];
return why && `${L("B08_Quantity_Summary_LampTip")} — ${why}`;
},
`td.${LAMP_CLASS}`,
);
return table.root;
}
@@ -352,18 +352,21 @@ export function createMain(
async function mount(doc: unknown, at: Selection): Promise<void> {
const readOnly = !writable(at);
const mine = seq; // 모듈 · 집계표 번호를 기다리는 사이 다른 화면(옵션 정의 등)이 열렸으면 그만둠
host.replaceChildren();
if (at.kind === "table") {
const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
SHEET_MODS,
"createSheet",
);
if (mine !== seq) return;
if (!create) return empty(L("M02_NoSheet"));
// 구조물집계표(프로젝트 층 아님)만 — 도면 · 면적산출 줄에 번호 · 칸을 고르면 아래 컨테이너 ·
// 고칠 수 있으면 일위대가 · 도면 · 면적산출 칸은 조회 후 선택
const struct =
at.layer !== "project" && at.name === STRUCTURE_TABLE ? buildPanel(at.layer) : null;
if (struct) await struct.reload();
if (mine !== seq) return struct?.destroy();
panel = struct;
// ⚠ sheet 는 바깥(다시 열기 등으로 drop() 이 null 로 되돌리는) 값 — 표 부품이
// 헐린 뒤에도 안 떼인 이벤트가 이 줄을 부르면 sheet?.getDoc() 가 undefined 라
@@ -417,6 +420,7 @@ export function createMain(
// 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 ·
// 단가승격 · 사전조합(50-3) = 같은 상세 모드(도면 · 면적 · 자재 없음 · 이름 = 키)
const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail");
if (mine !== seq) return;
const mode = isPrecombo(at.kind) ? at.kind : undefined;
const labor = mountLaborDetail(host, doc, {
layer: at.layer,
@@ -447,6 +451,7 @@ export function createMain(
DRAWING_MODS,
at.kind === "structure" ? "mountStructureSheets" : "mountDrawingTemplate",
);
if (mine !== seq) return;
if (mountDrawing) {
editor = mountDrawing(host, doc, {
onSave: () => void doSave(),
+11
View File
@@ -50,6 +50,17 @@ class BermSpec(NamedTuple):
interval_m: float = BERM_DEFAULT_INTERVAL_M
slope_deg: float = BERM_DEFAULT_SLOPE_DEG
depth_m: float = BERM_DEFAULT_DEPTH_M
# 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼ · 측점 patch) — None = `depth_m`
depth_left_m: float | None = None
depth_right_m: float | None = None
def side_berm(berm: BermSpec | None, side: str) -> BermSpec | None:
"""그 쪽 소단 — 쪽마다 옮긴 깊이가 있으면 그 값을 `depth_m` 으로(194-1 · 0 미만 = 0)."""
if berm is None:
return None
own = berm.depth_left_m if side == "left" else berm.depth_right_m
return berm if own is None else berm._replace(depth_m=max(float(own), 0.0))
def cut_profile_points(
+10
View File
@@ -39,6 +39,16 @@ export interface BermSpec {
intervalM: number;
slopeDeg: number;
depthM?: number;
/** 쪽마다 옮긴 깊이(194-1 · 횡단도 ▲▼ · 측점 patch) — 없으면 `depthM`. */
depthLeftM?: number | null;
depthRightM?: number | null;
}
/** 짝: `side_berm`. 그 쪽 소단 — 쪽마다 옮긴 깊이가 있으면 그 값을 `depthM` 으로(0 미만 = 0). */
export function sideBerm(berm: BermSpec | null, side: string): BermSpec | null {
if (berm === null) return null;
const own = side === "left" ? berm.depthLeftM : berm.depthRightM;
return own === undefined || own === null ? berm : { ...berm, depthM: Math.max(own, 0) };
}
export function bermSpec(
+9
View File
@@ -143,6 +143,9 @@ export interface CrossDesignResult {
/** 노견 확장(m · 139-6) — 넓힌 쪽만. */
shoulder_extend_left_m?: number;
shoulder_extend_right_m?: number;
/** 소단 쪽마다 옮긴 깊이(m · 194-1) — 옮긴 쪽만. */
berm_depth_left_m?: number;
berm_depth_right_m?: number;
cross_slope_pct: number;
ditch: Record<string, unknown>;
/** 결과 — **실제로 섰나**. */
@@ -588,6 +591,12 @@ export function computeCrossDesign(
slope_deg: round4(options.berm.slopeDeg),
depth_m: round4(Math.max(options.berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0)),
};
// 쪽마다 옮긴 깊이(194-1) — 옮긴 쪽만 되싣는다(짝: 파이썬)
const { depthLeftM, depthRightM } = options.berm;
if (depthLeftM !== undefined && depthLeftM !== null)
result.berm_depth_left_m = round4(Math.max(depthLeftM, 0));
if (depthRightM !== undefined && depthRightM !== null)
result.berm_depth_right_m = round4(Math.max(depthRightM, 0));
}
// 노견 확장(139-6) — 넓힌 쪽만 되싣는다(짝: 파이썬)
if (geometry.shoulderExtendLeft > 0)
@@ -11,6 +11,7 @@ import {
cutProfilePoints,
elevationAt,
fillProfilePoints,
sideBerm,
} from "./common_util_cross_berm";
/** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */
@@ -306,7 +307,7 @@ export class SectionGeometry {
startDist,
startZ,
this.fillRatio,
this.berm,
sideBerm(this.berm, side), // 쪽마다 옮긴 깊이(194-1)
undefined,
edgeZ,
);
+37
View File
@@ -0,0 +1,37 @@
# 2026-10-10 계획 194~199장 기록 — 소단 위아래 · 보라선 글 · 알림 줄 칩 · 대시보드 카드 · B08 램프 · B08 표 높이
근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 46~52 · 메인 합침 d2f76e03b.
## 194. 소단 = 좌측 위치 칸 없앰 · 횡단에서 위아래로만 옮김 (sub7)
정함(브레인) = 사용자 46번 · 178-2 의 좌측 「소단 위치(도로 아래)」 칸 지움 · 기본 = 노면 끝 아래 1 m · 사용자가 횡단도에서 소단 선을 골라 ▲▼ 로만 옮김(◀▶ 막음 · 성토 사면 무리 이동과 안 부딪힘 · 193 축 제약 꼴) · 옮긴 값 = 측점마다 횡단 설계 patch(정본 · [저장] 때 서버 다시 셈) · 도로보다 높을 수 없음
- [x] 194-1 칸 지움 · 소단 선 고름 = ▲▼ 만 · 측점별 깊이 patch · 짝 거울 — test_178_2 고침 · 새 시험
- 검증완료 — 새 test_194_1_berm_depth 14(칸 없음 · 기본 1 m · 쪽마다 깊이 거울 · patch 왕복 · 서버 = 정본 구간 + 측점 깊이 · 구간 지우면 소단 없음 · ▲▼ 만 · 0 · 사면 끝 멈춤) + test_178_2 · 54_26 · 193_1 · 139_6 · 137 · 사용자값 짝 등 합 86 통과 · typecheck 통과
## 195. B06 보라 선 고를 때 정보 글 = 길이 · 경사비율 · 수평 · 높이 (sub1)
정함(브레인) = 사용자 47번 · 「사면 길이」 → 「길이」 · 차례 = 길이 → 경사비율 → 수평 → 높이 · 값 · 단위 셈은 그대로
- [x] 195-1 보라 선 고름 정보 글(선 길이 정보창 · 고름 패널 둘 다 같은 글이면 둘 다) — 새 시험
- 검증완료 — `pieceLabel` 한 곳(정보창 글만 · 고름 패널 제목은 이동 글이라 무변) · 새 test_195_1 + test_137 · 179_1 · 193_1 · 139_6 합 24 통과 · typecheck 통과
- [x] 194-2 (48번 보충) 소단 고름 = 횡단도 소단 수평선 강조 + 좌측 패널 · 소단 컨테이너 켬 · 이동 셋 분리(성 · 절토 = X · 도로 = Y · 소단 = 높이 Y) · 방향키로 옮긴 뒤 다시 그려도 같은 선 조각이 고른 채 남음(다른 조각으로 넘어가지 않음 · 조각 키 = 갈래 + 쪽 + 차례) — 새 시험
- 검증완료 — test_194_1 흉내 DOM: 갈래 축(소단 depth · 사면 x · 노면 y) · 그리는 차례 · 깊이 바꿔 다시 그려도 같은 조각(번호 1 → 6 · 키로 되살림) · 좌측 소단 상자 알림 한 번(켬 40 → 끔) · 합 86 통과
## 196. B06 횡단 맨 위 알림 줄 — 군더더기 지움 · 이름 줄임 · 내용 칸 하나 공유 (sub8)
정함(브레인) = 사용자 49번 · 알림 줄(B06_Section_UI_Cross_FillSlope_Notice · 성토사면 5m 초과 · 소단 검토 · 포장 권장 · 열 수 칸)과 그 위 줄(측점 없는 구조물 등) · 항목 = 짧은 이름 + 수(예 「성토 5m↑ 3」 「소단 검토 5」 「포장 권장 2」) · 긴 설명 글은 지우고 공용 호버(185-3 ui_template_hover_info)로 · 펼침은 항목마다 따로가 아니라 줄 아래 내용 칸 하나를 같이 씀(누른 항목 목록만 · 다시 누르면 닫힘 · 한 번에 하나) · 지운 것 목록은 보고에
- [x] 196-1 알림 줄 칩 꼴 · 내용 칸 하나 · 설명 = 호버 · sticky · 열 수 칸 그대로 — 새 시험
- 검증완료 — 새 `_Cross_Notice_Chips`(칩 + 내용 칸 하나 · 호버) · 측점 없는 구조물 줄 까닭 글 = 호버 · 새 test_196_1(7) + fill_slope_warn · berm_review · 138 열 수 · 185_3 호버 합 19 통과 · typecheck 통과
## 197. 대시보드 「구조물 옵션」 카드 → M02 구조물 옵션 정의로 바로 (sub1)
정함(브레인) = 사용자 50번 · 카드가 M02 open=옵션 으로 감 → M02_UI_Side reveal("옵션") 이 옵션 묶음 첫 단추를 누름(구조물 옵션 정의 줄이 아닐 수 있음 · 추정) · 다른 카드(구조물조합 · 도면)도 같이 확인
- [x] 197-1 대시보드 카드 → M02 「구조물 옵션 정의」 화면이 바로 열림 · 구조물조합 · 도면 카드도 제자리 — 새 시험
- 원인 — 해시 open=옵션 이 옵션 줄을 눌러 정의 화면을 띄우는 사이 첫 화면 집계표의 `mount` 가 모듈 · 번호를 기다리다 뒤늦게 끝나 정의 화면 위에 덮음(`mount` 에 낡음 검사 없음) · 카드 → 해시 · reveal 길은 맞았음
- 고침 — `M02_MasterTemplete_UI_Main.ts` `mount` 기다림 뒤마다 `mine !== seq` 면 그만둠(집계표 · 일위대가 상세 · 도면)
- 검증완료 — 새 test_197_1(4: 카드 셋 해시 · 해시 진입 셋 · reveal 옵션만 누름 · mount 낡음 검사 Node 3경우 · 고침 빼면 실패 확인) + b01_settings_cards · m02_layer_screen · card_shell · structure_def_screen 포함 38 통과 · M02_UI_Main 쓰는 시험 116 중 115 통과(test_m02_card_sheet_edit 1건 = spreadsheet.ts 문구 시험 · 이번 고침 밖) · typecheck 통과
## 198. B08 구조물 집계표 — 미연결 = 줄 왼쪽 램프 하나 · 미지정 열 지움 (sub8)
정함(브레인) = 사용자 51번 · 160 · 162 때 더한 「미지정」 열 지움 · 줄 맨 왼쪽에 작은 램프 하나(미연결 = 경고 노랑 · 연결 = 안 보임 또는 초록 · 177-1 · 170-3 상태 색 토큰 재사용) · 램프에 마우스 = 공용 호버로 까닭(어느 조합이 미연결인지)
- [x] 198-1 미지정 열 지움 · 줄 왼쪽 램프 · 호버 까닭 · 엑셀 꼴 · 합계 영향 없음 — test_160 · 162 등 고침 · 새 시험
- 검증완료 — test_198_1 4건(미연결 줄 측점 칸만 램프 1개 · 호버 「구조물 조합 미연결 — 빈 칸」 · 경고 노랑 토큰 · 줄 값 = 측점 + 서버 값뿐) + B08 시험 12파일 57 통과 · typecheck 0 · 구조물 집계표는 엑셀 내보내기 없음 · 「· 미연결」 측점 글 · 조합 칸 「미지정」 표기도 지움 · 서버 안내 「노랑 램프 줄」
## 199. B08 표 높이 = 브라우저 창 아래 끝까지(페이지 스크롤 없음) (sub5)
정함(브레인) = 사용자 52번 · 합계 줄 고정 + 안쪽 스크롤은 그대로 · 표 칸 최대 높이 = 고정값 대신 창 높이에서 표 위쪽 자리를 뺀 만큼(창 크기 바뀌면 다시 셈) · 줄이 적으면 내용만큼 · 공용 표 양식에 옵션 하나(다른 페이지도 쓸 수 있게) · 이번 적용은 B08 표들
- [x] 199-1 B08 표 칸 = 창 아래 끝까지 · 페이지 스크롤 0 · 합계 줄 고정 유지 · 창 크기 바뀜 대응 — 새 시험
- 검증완료 — test_199_1 12건(옵션 fillViewport · 최대 높이 = 창 − 위 − 아래 자리 · 창 크기 바뀜 900→600 재셈 · 스크롤 무관 · 아래 요소 늘면 줄어듦 · 합계 줄 sticky 그대로 · B08 표 5곳 적용) + 관련 B08 시험 · test_ui_data_table 통과 · typecheck 0 · test_124 「화면 연결」 1건 실패는 이 변경 전부터(HEAD 에 해당 글 없음)
@@ -3573,6 +3573,7 @@
"weep_hole_area_m2": false
},
"berm": {
"depth_m": false,
"height_m": false,
"length_m": false,
"before_m": false,
@@ -3807,8 +3808,7 @@
"id": "gbermdetail",
"이름": "상세 내용",
"옵션": [
"width_m",
"depth_m"
"width_m"
],
"형상": {}
}
+304
View File
@@ -0,0 +1,304 @@
/* 194-1 · 194-2 소단 고름 헬퍼 — `_Cross_Design_Pick` 을 그 자리에서 트랜스파일해 흉내 DOM 으로 돌림.
* 소단 = 높이만(▲▼ · ◀▶ 막힘 · 노면 끝 위 금지) · 조각 키로 다시 그려도 같은 조각 · 좌측 상자 알림. 짝 = `test_194_1_berm_depth.py`.
*/
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();
class Node {
constructor(tag) {
this.tagName = tag;
this.children = [];
this.attrs = {};
this.listeners = {};
this.hidden = false;
this.isConnected = true;
this._text = "";
this._cls = new Set();
const self = this;
this.classList = {
add: (...n) => n.forEach((c) => self._cls.add(c)),
toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)),
contains: (c) => self._cls.has(c),
};
}
get descendants() {
return this.children.flatMap((c) => [c, ...c.descendants]);
}
/** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */
querySelector(sel) {
return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null;
}
getClientRects() {
return this._cls.has("is-hidden") ? [] : [{}];
}
set className(v) {
this._cls = new Set(String(v).split(/\s+/).filter(Boolean));
}
set textContent(v) {
this._text = String(v);
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
append(...kids) {
this.children.push(...kids);
}
insertBefore(kid) {
this.children.push(kid);
}
setAttribute(n, v) {
this.attrs[n] = String(v);
if (n === "class") this.className = v;
}
getAttribute(n) {
return this.attrs[n] ?? null;
}
hasAttribute(n) {
return n in this.attrs;
}
addEventListener(t, fn) {
(this.listeners[t] ??= []).push(fn);
}
click() {
(this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined }));
}
}
const docListeners = {};
globalThis.document = {
createElement: (t) => new Node(t),
createElementNS: (_n, t) => new Node(t),
addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn),
removeEventListener: (t, fn) => {
docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn);
},
};
const key = (name) =>
[...(docListeners.keydown ?? [])].forEach((fn) =>
fn({ key: name, target: null, preventDefault: () => undefined }),
);
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 req = (r) =>
r.startsWith("@util/")
? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`))
: loadTs(path.join(path.dirname(full), `${r}.ts`));
new Function("exports", "module", "require", js)(box.exports, box, req);
cache.set(full, box.exports);
return box.exports;
}
globalThis.Element = Node;
const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts"));
const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽
const y = (e) => 900 - (e - 100) * 12;
const line = (pts, structure = false) => {
const el = new Node("polyline");
el.setAttribute("class", "b06-chart__design-cross");
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
if (!structure) el.setAttribute(pick.DESIGN_LINE_ATTR, "");
return el;
};
/** 방향키 한 번 — 막혔나(preventDefault) 돌려줌. */
const press = (name) => {
let prevented = false;
[...(docListeners.keydown ?? [])].forEach((fn) =>
fn({ key: name, target: null, preventDefault: () => (prevented = true) }),
);
return prevented;
};
/** 양성토 단면(노면 끝 표고 100) — 좌 소단 깊이 `dl`(노면 끝 아래) · 우 1.0 · 사면 1:1.5 · 끝 표고 97. `order` = 그리는 차례. */
function card({ dl = 1, stored = undefined, order = ["left", "road", "right"], stationId } = {}) {
for (const k of Object.keys(docListeners)) if (k === "keydown") docListeners[k] = [];
const design = {
road_edges: {
left: { offset_m: 2, elevation_m: 100 },
right: { offset_m: -2, elevation_m: 100 },
},
ditch: null,
ditch_side: null,
ditch_enabled: false,
section_mode: "both_fill",
berm: { width_m: 0.5, interval_m: 3, slope_deg: 0, depth_m: 1 },
};
if (stored !== undefined) design.berm_depth_left_m = stored;
const slope = (s, d) => {
const k = s === "left" ? 1 : -1;
const knee = 2 + 1.5 * d;
const toe = knee + 0.5 + 1.5 * (100 - d - 97);
return [
[k * toe, 97],
[k * (knee + 0.5), 100 - d],
[k * knee, 100 - d],
[k * 2, 100],
];
};
const lines = {
left: line(slope("left", dl)),
road: line([
[2, 100],
[0, 100.04], // 노면 횡단경사 2 % — 깊이 0 소단이 노면과 한 직선으로 안 합쳐짐
[-2, 100],
]),
right: line(slope("right", 1)),
};
const layer = new Node("g");
layer.append(...order.map((name) => lines[name]));
const values = { left: 0, right: 0 };
const sets = [];
const focus = [];
const handle = pick.attachDesignLinePick({
layer,
design,
x,
toDisplayY: y,
onPick: () => undefined,
shift: { valueOf: (s) => values[s], set: (s, v) => (values[s] = v) },
berm: { set: (s, v) => sets.push([s, v]), focus: (on) => focus.push(on) },
stationId,
});
const find = (test) => handle.pieces.findIndex(test);
const mid = (p) => (p.from.offset + p.to.offset) / 2;
const at = {
leftBerm: find((p) => pick.isBermPiece(design, p) && mid(p) > 0),
rightBerm: find((p) => pick.isBermPiece(design, p) && mid(p) < 0),
leftUpper: find((p) => !pick.isBermPiece(design, p) && mid(p) > 2 && mid(p) < 2 + 1.5 * dl),
road: find((p) => mid(p) > 0 && mid(p) < 2),
};
const slot = (name) =>
handle.panel.descendants.find(
(n) => n._cls.has(`b06-structure-panel__btn--${name}`) && !n.parentHidden,
);
const rows = () =>
handle.panel.descendants
.filter((n) => n._cls.has("b06-structure-panel__struct"))
.map((n) => n._cls.has("is-hidden"));
const text = (cls) => handle.panel.descendants.find((n) => n._cls.has(cls)).textContent;
return { design, handle, values, sets, focus, at, slot, rows, text };
}
const out = {};
// ① 갈래 · 축 — 소단 = depth · 사면 = x · 노면 = y · 키
{
const c = card();
out.axis = Object.fromEntries(
Object.entries(c.at).map(([name, i]) => [
name,
pick.pieceMoveAxis(c.design, c.handle.pieces[i]),
]),
);
out.keys = pick.pieceKeys(c.design, c.handle.pieces).sort();
}
// ② 소단 고름 — ▲▼ 만 · ◀▶ 막힘 · ↺ = 구간 기본 · 좌측 상자 켬 알림
{
const c = card();
c.handle.setActive(c.at.leftBerm);
const [moveHidden, liftHidden] = c.rows();
const leftBlocked = press("ArrowLeft");
const rightBlocked = press("ArrowRight");
const afterSide = { values: { ...c.values }, sets: c.sets.length };
const upTaken = press("ArrowUp");
press("ArrowDown");
c.handle.panel.descendants
.filter((n) => n._cls.has("b06-structure-panel__btn--reset"))
.at(-1)
.click();
out.berm = {
title: c.text("b06-structure-panel__title"),
readout: c.text("b06-structure-panel__value"),
moveHidden,
liftHidden,
leftBlocked,
rightBlocked,
afterSide,
upTaken,
sets: c.sets,
focus: c.focus,
};
c.handle.setActive(c.at.leftUpper); // 사면으로 바꿈 — 소단 상자 끔 · ▲▼ 막힘 · 깊이 안 바뀜
const before = c.sets.length;
out.berm.slopeUpBlocked = press("ArrowUp");
out.berm.slopeSets = c.sets.length - before;
out.berm.slopeTitle = c.text("b06-structure-panel__title");
out.berm.focusAfter = c.focus.at(-1);
// 노면 = ▲▼ 는 종단도로 넘김(안 막음)
c.handle.setActive(c.at.road);
out.berm.roadUpPassed = !press("ArrowUp");
out.berm.roadSets = c.sets.length - before;
}
// ③ 도로 위 금지 = 0 에서 멈춤 · 사면 끝 앞에서 멈춤
{
const top = card({ dl: 0, stored: 0 });
top.handle.setActive(top.at.leftBerm);
press("ArrowUp");
const deep = card({ dl: 2.9, stored: 2.9 });
deep.handle.setActive(deep.at.leftBerm);
press("ArrowDown"); // 2.9 → 3.0 = 사면 끝(97) — 막힘
press("ArrowUp");
out.limit = { topSets: top.sets, topFound: top.at.leftBerm >= 0, deepSets: deep.sets };
}
// ④ 옮긴 뒤 다시 그려도 같은 조각(194-2) — 그리는 차례 · 깊이가 바뀌어도
{
const first = card({ stationId: "S1" });
pick.setPickedLine("S1", first.at.leftBerm);
const again = card({
dl: 1.1,
stored: 1.1,
order: ["right", "road", "left"],
stationId: "S1",
});
const restored = pick.pickedLineAt("S1");
pick.setPickedLine("S1", again.at.leftUpper);
const third = card({ dl: 1.2, stored: 1.2, stationId: "S1" });
out.stable = {
firstIndex: first.at.leftBerm,
againIndex: again.at.leftBerm,
restored,
upperRestored: pick.pickedLineAt("S1") === third.at.leftUpper,
upperIndexMoved: again.at.leftUpper !== third.at.leftUpper,
};
pick.setPickedLine("S1", null);
}
// ⑤ 카드 창구 — 소단 깊이 변경 · 좌측 상자 알림(한 번만 · 이 측점이 켠 때만 끔)
{
const changes = [];
const design = { ground_type: "soil", section_mode: "both_fill", shoulder_extend_left_m: 0.3 };
const moves = pick.designLineMoves({ station_id: "S9", chainage_m: 40, design }, (ch, change) =>
changes.push([ch, change]),
);
moves.berm.set("left", 1.3);
const host = [];
pick.setBermPickHost((ch) => host.push(ch));
moves.berm.focus(true);
moves.berm.focus(true); // 다시 그린 카드 — 안 부름
pick
.designLineMoves({ station_id: "S8", chainage_m: 20, design }, () => undefined)
.berm.focus(false);
moves.berm.focus(false);
out.moves = {
stationId: moves.stationId,
shiftValue: moves.shift.valueOf("left"),
change: changes[0],
host,
none: pick.designLineMoves({ station_id: "S7", chainage_m: 0, design }),
};
pick.setBermPickHost(null);
}
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,135 @@
/* 창 아래 끝까지 칸(`ui_template/ui_template_data_table.ts` · PLAN 199-1) 단위검증 헬퍼 —
* 트랜스파일해 가짜 DOM(요소 트리 · 창 · ResizeObserver)에서 돌리고 판정할 값을 JSON 으로 냄. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts");
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
// ── 가짜 DOM ──
const listeners = { resize: new Set() };
const observers = [];
const win = {
innerHeight: 900,
scrollY: 0,
addEventListener: (t, f) => listeners[t]?.add(f),
removeEventListener: (t, f) => listeners[t]?.delete(f),
};
class RO {
constructor(fn) {
this.fn = fn;
this.targets = new Set();
observers.push(this);
}
observe(el) {
this.targets.add(el);
}
disconnect() {
this.targets.clear();
}
}
function el(name, css = {}, height = 0) {
const node = {
name,
css,
height,
top: 0,
parentElement: null,
nextElementSibling: null,
isConnected: false,
style: {},
getBoundingClientRect() {
return { top: this.top - win.scrollY, height: this.height };
},
};
return node;
}
function link(parent, ...kids) {
kids.forEach((k, i) => {
k.parentElement = parent;
k.nextElementSibling = kids[i + 1] ?? null;
});
parent.kids = kids;
return parent;
}
const sty = (n) => ({
rowGap: n.css.rowGap ?? "normal",
paddingBottom: n.css.paddingBottom ?? "0px",
borderBottomWidth: n.css.borderBottomWidth ?? "0px",
marginBottom: n.css.marginBottom ?? "0px",
});
const doc = { body: null };
const box = { exports: {} };
const sandbox = {
window: win,
document: doc,
getComputedStyle: sty,
ResizeObserver: RO,
};
new Function("exports", "module", "require", ...Object.keys(sandbox), js)(
box.exports,
box,
(p) => (p.endsWith(".css") ? {} : require(p)),
...Object.values(sandbox),
);
const m = box.exports;
// 문서: html > body(padding-bottom 8) > page(padding-bottom 16 · gap 8) > [tabs 40] [card(padding-bottom 12) > [root][note 40]]
const html = el("html");
const body = el("body", { paddingBottom: "8px" });
const page = el("page", { paddingBottom: "16px", rowGap: "8px" });
const tabs = el("tabs", {}, 40);
const card = el("card", { paddingBottom: "12px", marginBottom: "4px" });
const root = el("root");
const note = el("note", {}, 40);
link(html, body);
link(body, page);
link(page, tabs, card);
link(card, root, note);
doc.body = body;
root.top = 200;
const out = { clamp: m.fillHeight(900, 200, 68), small: m.fillHeight(300, 250, 100) };
// 아래 자리 = note(40 + 줄 사이 0) + card 여백(12) + card 바깥 여백(4) + page 아래(16) + body 아래(8)
out.below = m.spaceBelow(root);
out.belowExpected = 40 + 0 + 12 + 4 + 16 + 8;
// 아직 문서에 안 붙음 — 높이 안 건드림
let fits = 0;
const stop = m.fillViewport(root, () => (fits += 1));
out.beforeAttach = root.style.maxHeight ?? null;
out.observed =
observers.some((o) => o.targets.has(root)) && observers.some((o) => o.targets.has(body));
// 붙음 → 관찰자가 부름
[html, body, page, tabs, card, root, note].forEach((n) => (n.isConnected = true));
observers.forEach((o) => o.fn());
out.first = root.style.maxHeight;
out.fits = fits;
// 창 크기 바뀜 → 다시 셈
win.innerHeight = 600;
[...listeners.resize].forEach((f) => f());
out.afterResize = root.style.maxHeight;
// 스크롤해도 같은 값(문서 좌표 기준)
win.scrollY = 80;
[...listeners.resize].forEach((f) => f());
out.afterScroll = root.style.maxHeight;
win.scrollY = 0;
// 아래 형제가 늘어남 → 그만큼 줄어듦
note.height = 100;
observers.forEach((o) => o.fn());
out.afterNoteGrow = root.style.maxHeight;
// 칸이 떨어지면 멈춤
root.isConnected = false;
[...listeners.resize].forEach((f) => f());
out.listenersAfterDetach = listeners.resize.size;
stop();
console.log(JSON.stringify(out));
+4 -4
View File
@@ -57,9 +57,9 @@ def test_치수_사면길이_수평_높이_구배(out) -> None:
assert abs(fill["lengthM"] - 13**0.5) < 1e-9
assert out["measure"]["road"]["grade"] == "2.0%" # 노면 = %
assert out["measure"]["flatSlope"] == "0.0%"
# 두 줄(151-1) — 1줄 = 길이 · 수평 · 2줄 = 높이 · 구배
assert out["label"]["fill"] == "사면길이 3.61m · 수평 3.00m\n높이 2.00m · 1:1.50"
assert out["label"]["road"] == "길이 2.50m · 수평 2.50m\n높이 0.05m · 2.0%"
# 두 줄(151-1 · 195-1) — 1줄 = 길이 · 경사비율 · 2줄 = 수평 · 높이
assert out["label"]["fill"] == "길이 3.61m · 경사비율 1:1.50\n수평 3.00m · 높이 2.00m"
assert out["label"]["road"] == "길이 2.50m · 경사비율 2.0%\n수평 2.50m · 높이 0.05m"
def test_구역_도로_절토_성토(out) -> None:
@@ -84,7 +84,7 @@ def test_실제_배관_측점_조각과_판정_선(out) -> None:
assert real["hitsBelowStructure"] is True # 구조물 판정면이 위
assert real["picked"] == [0] and real["stopped"] is True # 카드 고름으로 안 번짐
assert real["activeCount"] == 1 and real["activeIndex"] == 1
assert real["labelOn"]["hidden"] is False and real["labelOn"]["text"].startswith("사면길이 ")
assert real["labelOn"]["hidden"] is False and real["labelOn"]["text"].startswith("길이 ")
assert real["labelOff"] == {"hidden": True, "text": ""}
assert set(real["zones"]) <= {"road", "cut", "fill"}
@@ -154,7 +154,7 @@ def test_사본_없는_시설_알림_응답에_사본(root):
out = _run(root)
# 100(유출 흙막이) · 200 · 300 · 400 · 500(기본 날개벽 잇기) · 600 · 개거 700 = 7곳
assert not any("wing" in c["id"] for c in out["columns"])
assert "조합 사본 없는 시설 7곳 — 「미연결」 줄을 눌러 조합 연결" in out["notes"]
assert "조합 사본 없는 시설 7곳 — 노랑 램프 줄을 눌러 조합 연결" in out["notes"]
assert not any("관 연장 없음" in n for n in out["notes"])
assert set(out["linked"]) == {"pipe@100.0", "pipe@500.0__gford"}
@@ -187,7 +187,7 @@ def test_화면_조합_이름_구조물도는_사본에서():
ui = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
assert "loadPickerRows" not in ui # 층 목록 GET 안 씀
assert "linkedOnly(" in ui
assert "unlinkedAt(" in ui and "B08_Quantity_Summary_Unlinked" in ui # 154-1 미연결 줄
assert "unlinkedAt(" in ui and "B08_Quantity_Summary_LampTip" in ui # 154-1 · 198-1 미연결 램프
drawing = (ROOT / "B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8")
assert "readTemplate" not in drawing # 층 폴백 없음
assert "B08_Quantity_Sheets_NoCopy" in drawing
+6 -14
View File
@@ -1,4 +1,4 @@
"""160-2 B08 구조물 집계표 「미지정」 — 측점 옆 열 글 · 같은 조합 열 칸 표기(화면만).
"""160-2 B08 구조물 집계표 빈 조합 칸 글(198-1 부터 미연결 램프 호버 글 · 「미지정」 열 · 칸 표기는 지움).
`B08_Quantity_Summary_Data.ts` 를 `common_util_summary_item.ts` 와 이어 붙여 node 로 돌림(61-1 시험과 같은 꼴).
"""
@@ -47,31 +47,23 @@ FACILITIES = [
]
def test_미지정_글과_조합_칸():
def test_빈_조합_칸_글():
out = _run(
f"\nconst f = {json.dumps(FACILITIES, ensure_ascii=False)};"
"\nconst at = (s) => unlinkedAt(f, s);"
"\nconsole.log(JSON.stringify({"
" t100: unassignedText(at(100), '본체'), t160: unassignedText(at(160), '본체'),"
" t0: unassignedText(at(0), '본체'),"
" c100: [...unassignedCombos(at(100))], c160: [...unassignedCombos(at(160))] }));"
" t0: unassignedText(at(0), '본체') }));"
)
assert out["t100"] == "배관: 본체 · 관 / 세월교: 유출 구조"
assert out["t160"] == "BOX" and out["t0"] == ""
assert out["c100"] == ["system||SS2"] # 연결된 칸 · 고른 조합 없는 칸은 뺌
assert out["c160"] == []
def test_화면_연결():
summary = (ROOT / "B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
assert (
'unassignedText(at, L("B08_Quantity_Link_Body"))' in summary
'unassignedText(unlinkedAt(unlinked, station), L("B08_Quantity_Link_Body"))' in summary
) # 칸 이름 = 모달과 같은 규칙
assert (
'key: "unassigned", label: missingHead' in summary
and "frozen: hasMissing ? 2 : 1" in summary
)
assert "...markOf(here[i])," in summary and "filter((id) => comboIds.has(id))" in summary
assert '${L("B08_Quantity_Summary_Unlinked")}' in summary # 「· 미연결」 측점 표기 그대로
assert 'key: "unassigned"' not in summary and "unassignedCombos" not in summary # 198-1
locale = (ROOT / "ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8")
assert 'B08_Quantity_Summary_Unassigned: ["미지정", "Unassigned"]' in locale
assert "B08_Quantity_Summary_Unassigned" not in locale
@@ -199,7 +199,9 @@ def test_저장분_깊이_읽기():
def test_좌측_패널_칸_구간_깊이_나름():
"""구조물 옵션 depth_m → 세션 구간 → 측점 제원 · 서버 payload(같은 꼴)."""
"""옛 구조물 옵션 depth_m → 세션 구간 → 측점 제원(기본값) · 서버 payload(같은 꼴).
좌측 칸은 194-1 에서 숨김 — 옮기기는 횡단도 ▲▼(`test_194_1_berm_depth`)."""
source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
+339
View File
@@ -0,0 +1,339 @@
"""194-1 · 194-2 소단 = 좌측 위치 칸 없앰 · 횡단도에서 고른 소단을 ▲▼ 로만 옮김.
측점 · 쪽마다 깊이(`berm_depth_{side}_m`).
- 좌측 칸 없음(정의 그룹 · 표시) · 기본 = 노면 끝 아래 1 m
- 짝 거울: `common_util_cross_berm` · 설계 기하(파이썬 ↔ TS) — 쪽마다 깊이(`berm_depth_{side}_m`)
- 서버 [저장] 다시 셈: 구조물 정본 소단 구간 + 측점 patch 깊이(옛 구간 depth_m = 기본값)
- 화면(흉내 DOM · `helper_194_1_berm_pick.cjs`): ▲▼ 만 · ◀▶ 막힘 · 도로 위 금지 ·
옮긴 뒤 같은 조각 ·
좌측 소단 상자 알림
"""
import json
import re
import subprocess
from pathlib import Path
import pytest
from pydantic import ValidationError
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
berm_from,
enforce_design_lines,
stored_berm,
)
from B06_Section.B06_Section_Schema import CrossSectionPatch
from common_util.common_util_cross_berm import BermSpec, side_berm
from config.config_system import STANDARD_CROSS_SECTION
ROOT = Path(__file__).resolve().parents[2]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
HELPER = Path(__file__).parent / "helper_194_1_berm_pick.cjs"
DEF = ROOT / "resources" / "master_template" / "구조물정의.json"
node_only = pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def _ground() -> list[dict]:
"""양성토 — 지반이 노면 끝보다 3 m 이상 낮음."""
return [
{"offset_m": o / 2, "elevation_m": round(96.0 + 0.02 * o / 2, 4), "valid": True}
for o in range(-40, 41)
]
def _berm(left=None, right=None, depth=1.0) -> BermSpec:
return BermSpec(0.5, 3.0, 0.0, depth, left, right)
CASES = [
{"name": "양쪽 기본 1.0", "berm": _berm()},
{"name": "좌 2.0 · 우 기본", "berm": _berm(left=2.0)},
{"name": "좌 0(노면 끝) · 우 0.5", "berm": _berm(left=0.0, right=0.5)},
{"name": "좌 음수 = 0", "berm": _berm(left=-1.0)},
]
def _python(berm: BermSpec) -> dict:
return compute_cross_design(
_ground(), 100.0, standard=STANDARD_CROSS_SECTION, ground_type="soil",
section_mode="both_fill", berm=berm,
) # fmt: skip
def _flat_z(design: dict, side: str) -> list[float]:
"""그 쪽 노면 끝 밖 평탄 도막(소단) 표고 — 지반(≤ 96.4)에 닿기 전 사면만."""
edge = design["road_edges"][side]["offset_m"]
sign = 1 if side == "left" else -1
line = sorted(
(
p
for p in design["design_line"]
if sign * p["offset_m"] >= sign * edge - 1e-6 and p["elevation_m"] > 96.5
),
key=lambda p: sign * p["offset_m"],
)
flats = {
round(a["elevation_m"], 3) # 설계선 표본이 한 단을 여러 도막으로 끊어도 한 표고
for a, b in zip(line, line[1:], strict=False)
if abs(b["offset_m"] - a["offset_m"]) > 1e-6
and abs(b["elevation_m"] - a["elevation_m"]) / abs(b["offset_m"] - a["offset_m"]) < 0.05
}
return sorted(flats)
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { computeCrossDesign } from "./common_util_cross_design.js";
const input = JSON.parse(readFileSync(process.argv[2], "utf8"));
const designs = input.cases.map((c) => computeCrossDesign(input.samples, 100, {
standard: input.standard, groundType: "soil", sectionMode: "both_fill",
berm: { widthM: c[0], intervalM: c[1], slopeDeg: c[2], depthM: c[3],
depthLeftM: c[4], depthRightM: c[5] },
}));
writeFileSync(process.argv[3], JSON.stringify(designs));
"""
@pytest.fixture(scope="module")
def ts_designs(tmp_path_factory) -> list[dict]:
tmp = tmp_path_factory.mktemp("berm194")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(ROOT / "common_util" / "common_util_cross_design.ts"),
"--outDir", str(out), "--module", "esnext", "--target", "es2022",
"--moduleResolution", "bundler", "--ignoreConfig"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
for emitted in out.glob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
src, dst = tmp / "in.json", tmp / "out.json"
src.write_text(
json.dumps(
{
"cases": [list(case["berm"]) for case in CASES],
"samples": _ground(),
"standard": STANDARD_CROSS_SECTION,
},
ensure_ascii=False,
),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(src), str(dst)], check=True, capture_output=True
)
return json.loads(dst.read_text(encoding="utf-8"))
def _close(left, right, path=""):
if isinstance(left, dict):
assert set(left) == set(right), f"{path} 키 다름 {set(left) ^ set(right)}"
for key in left:
_close(left[key], right[key], f"{path}.{key}")
elif isinstance(left, list):
assert len(left) == len(right), f"{path} 길이"
for i, (a, b) in enumerate(zip(left, right, strict=True)):
_close(a, b, f"{path}[{i}]")
elif isinstance(left, (int, float)) and not isinstance(left, bool):
assert abs(float(left) - float(right)) <= 1e-9, f"{path}: {left} vs {right}"
else:
assert left == right, f"{path}: {left!r} vs {right!r}"
# ── 좌측 칸 없음 · 기본 1 m ────────────────────────────────────────────────
def test_좌측_패널_깊이_칸_없음() -> None:
data = json.loads(DEF.read_text(encoding="utf-8"))
assert data["그룹"]["berm"][0]["옵션"] == ["width_m"]
assert data["표시"]["berm"]["depth_m"] is False
berm = next(t for t in data["types"] if t["type_id"] == "berm")
depth = next(o for o in berm["options"] if o["key"] == "depth_m")
assert depth["default"] == 1.0 # 옛 구간 값 · 새 구간 기본 = 노면 끝 아래 1 m
def test_기본_1m_수평_한_단() -> None:
design = _python(_berm())
for side in ("left", "right"):
edge_z = design["road_edges"][side]["elevation_m"]
assert _flat_z(design, side) == [pytest.approx(edge_z - 1.0, abs=1e-3)]
assert "berm_depth_left_m" not in design and "berm_depth_right_m" not in design
# ── 짝 거울 · 쪽마다 깊이 ───────────────────────────────────────────────────
def test_쪽마다_깊이_그_쪽만() -> None:
by = {case["name"]: _python(case["berm"]) for case in CASES}
two = by["좌 2.0 · 우 기본"]
assert _flat_z(two, "left") == [pytest.approx(two["road_edges"]["left"]["elevation_m"] - 2.0)]
assert _flat_z(two, "right") == [pytest.approx(two["road_edges"]["right"]["elevation_m"] - 1.0)]
assert two["berm_depth_left_m"] == 2.0 and "berm_depth_right_m" not in two
assert two["berm"]["depth_m"] == 1.0 # 구간 값은 그대로
top = by["좌 0(노면 끝) · 우 0.5"]
assert _flat_z(top, "left") == [pytest.approx(top["road_edges"]["left"]["elevation_m"])]
neg = by["좌 음수 = 0"]
assert neg["berm_depth_left_m"] == 0.0 # 도로보다 높을 수 없음
assert neg["design_line"] == _python(_berm(left=0.0))["design_line"]
assert side_berm(_berm(right=1.5), "right").depth_m == 1.5
assert side_berm(_berm(right=1.5), "left").depth_m == 1.0
@node_only
def test_파이썬_TS_거울(ts_designs: list[dict]) -> None:
for case, ts in zip(CASES, ts_designs, strict=True):
_close(_python(case["berm"]), ts, case["name"])
def test_짝_머리_표시() -> None:
for name in ("common_util_cross_berm.py", "common_util_cross_berm.ts"):
text = (ROOT / "common_util" / name).read_text(encoding="utf-8")
assert "짝" in text and ("side_berm" in text or "sideBerm" in text)
# ── 측점 patch 저장 왕복 · 서버 다시 셈 ─────────────────────────────────────
def test_patch_칸_왕복() -> None:
patch = CrossSectionPatch(chainage_m=40, berm_depth_left_m=1.5, berm_depth_right_m=0)
dumped = {k: v for k, v in patch.model_dump().items() if v is not None}
assert dumped == {"chainage_m": 40, "berm_depth_left_m": 1.5, "berm_depth_right_m": 0}
with pytest.raises(ValidationError):
CrossSectionPatch(chainage_m=40, berm_depth_left_m=-0.1) # 도로 위 금지
assert {"berm_depth_left_m", "berm_depth_right_m"} <= set(USER_TOUCHED_KEYS)
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
assert '"berm_depth_left_m",' in refresh and '"berm_depth_right_m",' in refresh
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert "patch.berm_depth_left_m = sides.left" in persist
def test_저장분_읽기_옛_구간값_기본() -> None:
old = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}})
assert (old.depth_m, old.depth_left_m, old.depth_right_m) == (2.5, None, None)
moved = stored_berm({"berm": {"width_m": 0.5, "depth_m": 2.5}, "berm_depth_left_m": 1.2})
assert (moved.depth_left_m, moved.depth_right_m) == (1.2, None)
# 프리뷰 payload(구간 제원 + 쪽 깊이)
spec = berm_from({"width_m": 0.5, "depth_m": 1.0, "berm_depth_right_m": -3}, {
"berm_depth_right_m": -3
}) # fmt: skip
assert spec.depth_right_m == 0.0
LONG = {
"design_profiles": [
{
"samples": [
{"chainage_m": 0, "elevation_m": 100},
{"chainage_m": 200, "elevation_m": 100},
]
}
]
}
def _project(tmp_path: Path, structures: list[dict]) -> Path:
route = tmp_path / "B05_Profile" / "route"
route.mkdir(parents=True)
(route / "structures.json").write_text(
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False),
encoding="utf-8",
)
return tmp_path
BERM_SPAN = {
"type_id": "berm",
"placement": "interval",
"start_m": 0,
"end_m": 100,
"options": {"width_m": 0.5, "depth_m": 2.5}, # 옛 좌측 칸 값 = 기본값
}
def test_저장_때_서버가_정본_구간_측점_깊이로_다시_셈(tmp_path: Path) -> None:
root = _project(tmp_path, [BERM_SPAN])
browser = compute_cross_design(_ground(), 100.0, ground_type="soil", section_mode="both_fill")
# 브라우저 patch = 사용자 값(좌 깊이)만 · 설계선은 소단 없는 옛 것
stored = {**browser, "berm_depth_left_m": 1.5}
sections = [{"chainage_m": 40.0, "samples": _ground(), "design": stored}]
assert enforce_design_lines(LONG, sections, root) == 1
design = sections[0]["design"]
assert design["berm"]["depth_m"] == 2.5 and design["berm_depth_left_m"] == 1.5
left_z = design["road_edges"]["left"]["elevation_m"]
right_z = design["road_edges"]["right"]["elevation_m"]
assert _flat_z(design, "left") == [pytest.approx(left_z - 1.5, abs=1e-3)]
assert _flat_z(design, "right") == [pytest.approx(right_z - 2.5, abs=1e-3)]
# 구간 밖 측점 = 소단 없음 · 옮긴 값만 남음(다시 놓으면 그대로 씀)
outside = [{"chainage_m": 150.0, "samples": _ground(), "design": dict(stored)}]
enforce_design_lines(LONG, outside, root)
assert "berm" not in outside[0]["design"] and _flat_z(outside[0]["design"], "left") == []
def test_구간_지우면_저장분_소단도_사라짐(tmp_path: Path) -> None:
root = _project(tmp_path, [])
old = _python(_berm(left=1.5))
sections = [{"chainage_m": 40.0, "samples": _ground(), "design": old}]
enforce_design_lines(LONG, sections, root)
assert "berm" not in sections[0]["design"]
assert _flat_z(sections[0]["design"], "left") == []
# ── 화면 고름(흉내 DOM) ─────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def ui() -> dict:
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
@node_only
def test_이동_셋_분리(ui: dict) -> None:
assert ui["axis"] == {"leftBerm": "depth", "rightBerm": "depth", "leftUpper": "x", "road": "y"}
assert "berm:left:0" in ui["keys"] and "fill:left:1" in ui["keys"]
@node_only
def test_소단_고름_위아래만(ui: dict) -> None:
berm = ui["berm"]
assert berm["title"] == "소단 높이 (좌)" and berm["readout"] == "도로 아래 1.0m"
assert berm["moveHidden"] is True and berm["liftHidden"] is False
assert berm["leftBlocked"] and berm["rightBlocked"] # ◀▶ 막힘
assert berm["afterSide"] == {"values": {"left": 0, "right": 0}, "sets": 0} # 가로 안 감
assert berm["upTaken"]
assert berm["sets"] == [["left", 0.9], ["left", 1.1], ["left", 1]] # ▲ · ▼ · ↺ 기본
assert berm["slopeTitle"] == "사면 이동 (좌)" and berm["slopeUpBlocked"]
assert berm["slopeSets"] == 0 and berm["roadSets"] == 0
assert berm["roadUpPassed"] # 노면 ▲▼ = 종단도 몫
@node_only
def test_도로_위_금지_사면_끝_앞_멈춤(ui: dict) -> None:
assert ui["limit"]["topFound"] and ui["limit"]["topSets"] == [] # 깊이 0 에서 ▲ = 안 감
assert ui["limit"]["deepSets"] == [["left", 2.8]] # ▼ 막힘 · ▲ 는 감
@node_only
def test_옮긴_뒤_같은_조각(ui: dict) -> None:
stable = ui["stable"]
assert stable["firstIndex"] != stable["againIndex"] # 번호는 바뀜
assert stable["restored"] == stable["againIndex"] # 키로 같은 소단
assert stable["upperRestored"] and stable["upperIndexMoved"]
@node_only
def test_좌측_소단_상자_알림_카드_창구(ui: dict) -> None:
assert ui["berm"]["focus"][0] is True and ui["berm"]["focusAfter"] is False
moves = ui["moves"]
assert moves["stationId"] == "S9" and moves["shiftValue"] == 0.3
chainage, change = moves["change"]
assert chainage == 40 and change["berm_depth"] == {"side": "left", "valueM": 1.3}
assert change["ground_type"] == "soil" # 카드 버튼 값은 그대로 실음
assert moves["host"] == [40, None] # 한 번만 켬 · 다른 측점 끔은 무시
assert moves["none"] == {"stationId": "S7"} # 창구 없으면 이동 패널 없음
selection = (ROOT / "B06_Section" / "B06_Section_UI_Page_Structures_Selection.ts").read_text(
encoding="utf-8"
)
assert "setBermPickHost(" in selection and 'classList.add("is-active")' in selection
+19
View File
@@ -0,0 +1,19 @@
"""PLAN 195-1 — B06 보라 선 고름 정보 글: 「사면 길이」 → 「길이」 · 차례 = 길이 → 경사비율 → 수평 → 높이.
짝: `B06_Section/B06_Section_UI_Cross_Design_Pick.ts` `pieceLabel`. 실제 TS 를 `helper_137_design_pick.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import re
from test_137_design_pick import out # noqa: F401 — 같은 Node 도우미 결과를 씀
def test_글_차례_넷_길이_경사비율_수평_높이(out) -> None: # noqa: F811
for key in ("fill", "road"):
label = out["label"][key]
assert "사면" not in label
names = re.findall(r"(길이|경사비율|수평|높이)", label)
assert names == ["길이", "경사비율", "수평", "높이"], label
assert out["label"]["fill"].startswith("길이 ")
@@ -0,0 +1,195 @@
"""196-1 B06 횡단 맨 위 알림 줄 — 칩 꼴 · 짧은 이름 + 수 · 긴 설명 = 호버 · 내용 칸 하나 공유.
`B06_Section_UI_Cross_Notice_Chips.ts` 를 Node 로 그대로 돌림(작은 가짜 DOM · 호버는 받은 글만 기록).
줄 짜임(sticky · 열 수 칸 · 카드 scroll-margin)과 이름 · 설명 자리는 원문 대조.
"""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TS = ROOT / "config" / "node_modules" / "typescript"
B06 = ROOT / "B06_Section"
MOD = B06 / "B06_Section_UI_Cross_Notice_Chips.ts"
PRELUDE = f"""
const fs = require("fs");
const ts = require({json.dumps(str(TS))});
const js = ts.transpileModule(fs.readFileSync({json.dumps(str(MOD))}, "utf8"), {{
compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }},
}}).outputText;
class El {{
constructor(tag) {{
this.tag = tag; this.parentElement = null; this.children = []; this.on = {{}};
this.attrs = {{}}; this.dataset = {{}}; this.hidden = false; this.textContent = ""; this.cls = new Set();
const cls = this.cls;
this.classList = {{ toggle: (c, on) => (on ? cls.add(c) : cls.delete(c)), contains: (c) => cls.has(c) }};
}}
set className(v) {{ this.cls.clear(); v.split(/\\s+/).filter(Boolean).forEach((c) => this.cls.add(c)); }}
get className() {{ return [...this.cls].join(" "); }}
append(...cs) {{ cs.forEach((c) => {{ c.parentElement = this; this.children.push(c); }}); }}
replaceChildren(...cs) {{ this.children = []; this.append(...cs); }}
setAttribute(k, v) {{ this.attrs[k] = String(v); }}
getAttribute(k) {{ return this.attrs[k] ?? null; }}
addEventListener(t, f) {{ (this.on[t] ||= []).push(f); }}
closest(sel) {{
for (let x = this; x; x = x.parentElement)
if (sel.startsWith(".") ? x.cls.has(sel.slice(1)) : x.tag === sel) return x;
return null;
}}
click() {{
const e = {{ type: "click", target: this }};
for (let x = this; x; x = x.parentElement) (x.on.click || []).forEach((f) => f(e));
}}
}}
const document = {{ createElement: (t) => new El(t) }};
const hovers = [];
const fakeRequire = (name) => {{
if (name !== "@ui/ui_template_hover_info") throw new Error("뜻밖의 import " + name);
return {{ attachHoverInfo: (root, text, sel) => hovers.push({{ root, text, sel }}) }};
}};
const mod = {{ exports: {{}} }};
new Function("exports", "module", "require", "document", js)(mod.exports, mod, fakeRequire, document);
const picks = [];
const bar = mod.exports.createNoticeChips((id) => picks.push(id));
const [chipsRow, panel] = bar.root.children;
const chip = (key) => chipsRow.children.find((c) => c.dataset.noticeKey === key);
const view = () => ({{
chips: chipsRow.children.map((c) => [c.textContent, c.getAttribute("aria-expanded"), c.className]),
panelHidden: panel.hidden,
panelInfo: panel.classList.contains("is-info"),
rows: panel.children.map((b) => [b.textContent, b.dataset.stationId]),
}});
const row = (id) => ({{ stationId: id, text: "STA " + id }});
const items = (fill, pave, berm) => [
{{ key: "fill", label: "성토 5m↑", hint: "긴 성토 설명", tone: "warn", rows: fill.map(row) }},
{{ key: "pave", label: "포장 권장", hint: "긴 포장 설명", tone: "warn", rows: pave.map(row) }},
{{ key: "berm", label: "소단 검토", hint: "긴 소단 설명", tone: "info", rows: berm.map(row) }},
];
const out = {{}};
"""
def _node(body: str):
script = PRELUDE + body + "\nconsole.log(JSON.stringify(out));"
got = subprocess.run(
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert got.returncode == 0, got.stderr
return json.loads(got.stdout)
def test_칩_이름은_짧은_이름_더하기_측점_수_0이면_안_보임():
got = _node(
"""
bar.update(items(["a", "b", "c"], [], ["a", "d", "e", "f", "g"]));
out.v = view();
out.bar = bar.root.className;
out.tags = chipsRow.children.map((c) => [c.tag, c.type]);
"""
)
assert got["v"]["chips"] == [
["성토 5m↑ 3", "false", "b06-section__notice-chip b06-section__notice-chip--warn"],
["소단 검토 5", "false", "b06-section__notice-chip b06-section__notice-chip--info"],
]
assert got["tags"] == [["button", "button"], ["button", "button"]]
assert got["v"]["panelHidden"] is True and got["v"]["rows"] == []
def test_긴_설명은_공용_호버로_칩마다():
got = _node(
"""
bar.update(items(["a"], ["b"], ["c"]));
out.count = hovers.length;
out.root = hovers[0].root === chipsRow;
out.sel = hovers[0].sel;
out.hints = chipsRow.children.map((c) => hovers[0].text(c));
out.other = hovers[0].text({ dataset: {} }) ?? null;
"""
)
assert got["count"] == 1 and got["root"] and got["sel"] == ".b06-section__notice-chip"
assert got["hints"] == ["긴 성토 설명", "긴 포장 설명", "긴 소단 설명"]
assert got["other"] is None
def test_내용_칸_하나_한_번에_하나_다시_누르면_닫힘():
got = _node(
"""
bar.update(items(["a", "b"], ["p"], ["c"]));
out.panels = bar.root.children.length;
chip("fill").click();
out.open1 = view();
chip("berm").click();
out.open2 = view();
chip("berm").click();
out.closed = view();
"""
)
assert got["panels"] == 2 # 칩 줄 + 내용 칸 하나
o1 = got["open1"]
assert o1["panelHidden"] is False and o1["panelInfo"] is False
assert o1["rows"] == [["STA a", "a"], ["STA b", "b"]]
assert [c[1] for c in o1["chips"]] == ["true", "false", "false"]
o2 = got["open2"]
assert o2["rows"] == [["STA c", "c"]] and o2["panelInfo"] is True
assert [c[1] for c in o2["chips"]] == ["false", "false", "true"]
c = got["closed"]
assert c["panelHidden"] is True and c["rows"] == []
assert [x[1] for x in c["chips"]] == ["false", "false", "false"]
def test_측점_누르면_그_카드로_다시_그려도_연_칩_유지_0이면_닫힘():
got = _node(
"""
bar.update(items(["a", "b"], [], []));
chip("fill").click();
panel.children[1].click();
chipsRow.children[0].click(); chipsRow.children[0].click(); // 칩 누름은 측점 고름 아님
out.picks = picks.slice();
bar.update(items(["a", "b", "x"], [], []));
out.kept = view();
bar.update(items([], ["p"], []));
out.gone = view();
"""
)
assert got["picks"] == ["b"]
assert got["kept"]["rows"] == [["STA a", "a"], ["STA b", "b"], ["STA x", "x"]]
assert got["kept"]["chips"][0][:2] == ["성토 5m↑ 3", "true"]
assert got["gone"]["panelHidden"] is True and got["gone"]["chips"][0][:2] == [
"포장 권장 1",
"false",
]
def test_알림_줄은_칩을_쓰고_긴_설명은_호버_짧은_이름은_사전():
src = (B06 / "B06_Section_UI_Cross_FillSlope_Notice.ts").read_text(encoding="utf-8")
assert "<details" not in src and '"details"' not in src and "summary" not in src
assert "createNoticeChips(onPick)" in src
for label, hint in (
('L("B06_Notice_FillSlope")', "FILL_SLOPE_WARN_TEXT"),
('L("B06_Notice_PaveSuggested")', 'L("B06_Design_Paved_Suggested")'),
('L("B06_Notice_Berm")', "BERM_INFO_TEXT"),
):
assert f"label: {label}" in src and f"hint: {hint}" in src
loc = (ROOT / "ui_template" / "ui_template_locale_b2.ts").read_text(encoding="utf-8")
assert 'B06_Notice_FillSlope: ["성토 5m↑"' in loc
assert 'B06_Notice_PaveSuggested: ["포장 권장"' in loc
assert 'B06_Notice_Berm: ["소단 검토"' in loc
def test_sticky_열_수_칸_카드_scroll_margin_그대로():
view = (B06 / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8")
assert "fillSlopeNotice.root.append(columnsField.root)" in view
assert '"--b06-notice-h"' in view
css = (B06 / "B06_Section_UI_Style_Cross.css").read_text(encoding="utf-8")
bar = css[css.index(".b06-section__notice-bar {") :]
assert "position: sticky" in bar[: bar.index("}")]
assert "scroll-margin-top: calc(var(--b06-notice-h" in css
assert ".b06-section__notice-list[hidden]" in css and "max-height: 30vh" in css
def test_측점_없는_구조물_줄_긴_까닭은_호버():
src = (B06 / "B06_Section_UI_Missing_Stations.ts").read_text(encoding="utf-8")
assert '". 이 자리는' not in src
assert 'attachHoverInfo(text, "이 자리는 횡단도에도 안 서고 수량에서도 빠집니다.")' in src
@@ -0,0 +1,139 @@
"""197-1 대시보드 「구조물 옵션」 카드 → M02 「구조물 옵션 정의」 화면이 바로 열림 시험 — 조작은 사용자.
원인 — 진입 해시 `open=옵션` 이 옵션 줄을 눌러 정의 화면을 띄우는 사이 첫 화면(집계표)의 `mount` 가
모듈 · 번호를 기다리다 뒤늦게 끝나 정의 화면 위에 집계표를 덮음(`mount` 에 낡음 검사 없었음).
· 카드 셋 → 해시(open=구조물조합 · 옵션 · 도면) · 해시 → 진입(`entryFromHash`) Node 로 바로 돌림
· `reveal` 가 옵션만 줄을 누르고 도면은 펼치기만 · 구조물조합은 아무것도 안 함
· `mount` 집계표 갈래(진짜 소스 조각)를 Node 로 돌림 — 기다리는 사이 다른 화면이 열리면 그리지 않음
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
sys.path.insert(0, str(Path(__file__).parent))
NODE = ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"]
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _run(script: str, *args: str) -> object:
result = subprocess.run(
[*NODE, "-e", script, *args],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
@needs_node
def test_카드_셋_해시():
from test_b01_settings_cards import _cards
cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3)
assert [c[2].split("?")[1].split("&")[0] for c in cards[1:]] == [
"open=구조물조합",
"open=옵션",
"open=도면",
]
assert all("&root=master" in c[2] for c in cards[1:])
@needs_node
def test_해시_진입_셋():
layer = _text(M02 / "M02_MasterTemplete_Layer.ts")
fn = re.search(r"^export function entryFromHash\([\s\S]*?^}", layer, re.M)
assert fn, "entryFromHash 를 못 찾음"
opens = re.search(r"^const OPENS[^\n]*", layer, re.M)
roots = re.search(r"^const ROOTS[^\n]*", layer, re.M)
assert opens and roots
script = (
"const currentQuery = () => new URLSearchParams(process.argv[1]);\n"
"const defaultRoot = () => 'master';\n"
"type Root = string; type OpenTarget = string; type Entry = any; type SessionUser = any;\n"
f"{opens.group(0)}\n{roots.group(0)}\n"
+ fn.group(0).replace("export function", "function")
+ "\nconsole.log(JSON.stringify(entryFromHash(null)));"
)
for want in ("구조물조합", "옵션", "도면"):
got = _run(script, f"open={want}&root=master&company=3")
assert got["open"] == want and got["root"] == "master" and got["company"] == "3"
def test_reveal_옵션만_줄을_누름():
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
body = side.split("const reveal = ")[1].split("\n };")[0]
assert "if (!sec) return;" in body # 시스템 층 아니면 옵션 · 도면 줄 없음
assert 'sec.classList.remove("is-collapsed");' in body # 도면 = 펼치기만
assert (
'if (target === "옵션") options.body.querySelector<HTMLElement>("button")?.click();' in body
)
# 옵션 컨테이너 안 단추 = 「구조물 옵션 정의」 줄 하나뿐
options = _text(M02 / "StructOptions" / "M02_SO_Options.ts")
assert options.count('el("button"') == 1
assert "openDefScreen" in options
# 옵션 컨테이너 = 구조물 옵션 정의 줄 · 시스템 층에만
assert "옵션: section(options.title, options.body)," in side
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert 'if (entry.open !== "구조물조합") side.reveal(entry.open);' in page
@needs_node
def test_집계표_갈래_기다리는_사이_다른_화면이_열리면_그리지_않음():
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
start = main.index(" async function mount(doc: unknown, at: Selection)")
stop = main.index(' if (at.kind === "labor" || isPrecombo(at.kind)) {', start)
table = main[start:stop] + "\n}\n"
assert table.count("if (mine !== seq)") == 2 # 모듈 받은 뒤 · 번호 받은 뒤
script = (
"type Selection = any; type EditorHandle = any; type SheetEditorHandle = any;\n"
"type StructureColumn = any;\n"
"let seq = 0; let panel: any = null; let editor: any = null; let sheet: any = null;\n"
"let dirty = false; const log: string[] = [];\n"
"const STRUCTURE_TABLE = 'T'; const SHEET_MODS = {}; const L = (k: string) => k;\n"
"const loaded: any = null; const extras: any = {};\n"
"const host = { replaceChildren() {} }; const writable = () => true;\n"
"const empty = (m: string) => log.push('empty:' + m);\n"
"const comboView = (d: any) => d; const comboMerge = (_: any, d: any) => d;\n"
"const laborSumText = () => ''; const laborSumState = () => '';\n"
"const gate = (): [Promise<void>, () => void] => {\n"
" let go = () => {}; return [new Promise<void>((r) => (go = r)), () => go()];\n"
"};\n"
"let modGate = gate(); let numGate = gate();\n"
"const findExport = async () => {\n"
" await modGate[0]; return (_h: any, _d: any, _o: any) => (log.push('create'),"
" { destroy() {}, getDoc: () => ({}) });\n"
"};\n"
"const buildPanel = () => ({ root: {}, numbers: () => ({}), async reload() {\n"
" await numGate[0]; }, dirty: () => false, destroy: () => log.push('panel.destroy') });\n"
+ table
+ "\nasync function run(bumpAt: string): Promise<string[]> {\n"
" seq = 1; log.length = 0; panel = null; modGate = gate(); numGate = gate();\n"
" const going = mount({}, { layer: 'system', kind: 'table', name: STRUCTURE_TABLE,"
" projectId: null });\n"
" if (bumpAt === 'mod') seq++;\n"
" modGate[1](); await new Promise((r) => setTimeout(r, 5));\n"
" if (bumpAt === 'num') seq++;\n"
" numGate[1](); await going; return [...log, 'panel:' + (panel ? 'set' : 'null')];\n"
"}\n"
"console.log(JSON.stringify({ none: await run('none'), mod: await run('mod'),"
" num: await run('num') }));"
)
got = _run(script)
assert got["none"] == ["create", "panel:set"] # 안 끼어들면 그대로 그림
assert got["mod"] == ["panel:null"] # 모듈 기다리는 사이 다른 화면 — 그리지 않음
assert got["num"] == ["panel.destroy", "panel:null"] # 번호 기다리는 사이 — 패널도 버림
@@ -0,0 +1,114 @@
"""198-1 B08 구조물 집계표 — 미연결 = 측점 칸 왼쪽 램프 하나 · 「미지정」 열 지움.
- 칸 class: 화면 소스의 `cellClass` 를 떼어 공용 표 `renderDataTableHtml` 로 그림(Node) — 미연결 줄 측점 칸만 램프
- 호버 글: 화면 소스의 호버 함수를 떼어 가짜 칸으로 부름 — 「구조물 조합 미연결 — 빈 칸 이름」 · 연결 줄은 없음
- CSS: 램프 = 경고 노랑 토큰(177-1) · 밝음 · 어두움 둘 다
- 줄 값 = 측점 + 서버 값뿐(「미지정」 · 「· 미연결」 글 없음 → 수량 · 합계 그대로)
"""
import json
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TS = ROOT / "config" / "node_modules" / "typescript"
UI = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
PRELUDE = f"""
const fs = require("fs");
const ts = require({json.dumps(str(TS))});
const js = (code) => ts.transpileModule(code, {{
compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }},
}}).outputText;
"""
def _node(script: str):
out = subprocess.run(
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60
)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
def _lamp_class() -> str:
m = re.search(r'const LAMP_CLASS = "([^"]+)";', UI)
assert m
return m.group(1)
def test_미연결_줄_측점_칸만_램프():
cell = re.search(r"cellClass: (\(i, key\) => .*?),\n", UI)
assert cell
table = json.dumps(str(ROOT / "ui_template" / "ui_template_data_table.ts"))
got = _node(
PRELUDE
+ f"""
const box = {{ exports: {{}} }};
new Function("exports", "module", "require", js(fs.readFileSync({table}, "utf8")))(
box.exports, box, (p) => (p.endsWith(".css") ? {{}} : require(p)));
const LAMP_CLASS = {json.dumps(_lamp_class())};
const missing = ["", "BOX", ""];
const cellClass = {cell.group(1)};
const html = box.exports.renderDataTableHtml({{
columns: [{{ key: "station", label: "측점" }}, {{ key: "c1", label: "조합" }}],
rows: [{{ station: "0+000" }}, {{ station: "0+020" }}, {{ station: "0+040", c1: 1 }}],
frozen: 1, empty: "", cellClass }});
const rows = html.split("<tbody>")[1].split("</tr>");
console.log(JSON.stringify({{
count: html.split(LAMP_CLASS).length - 1,
row1: rows[1],
row0: rows[0],
}}));
"""
)
assert got["count"] == 1
assert f'<td class="is-frozen is-edge {_lamp_class()}"' in got["row1"] # 측점 칸(첫 칸)
assert _lamp_class() not in got["row0"]
def test_호버_글_까닭_한_줄():
hover = re.search(
r"attachHoverInfo\(\n\s*table\.root,\n\s*(\(cell\) => \{.*?\n\s*\}),\n", UI, re.S
)
assert hover
got = _node(
PRELUDE
+ f"""
const L = (k) => ({{ B08_Quantity_Summary_LampTip: "구조물 조합 미연결" }})[k];
const missing = ["", "배관: 본체 · 관 / 세월교: 유출 구조"];
const text = eval(js("(" + {json.dumps(hover.group(1))} + ")"));
const cell = (i) => ({{ closest: () => ({{ dataset: {{ i: String(i) }} }}) }});
console.log(JSON.stringify([text(cell(1)), text(cell(0)) || null]));
"""
)
assert got == ["구조물 조합 미연결 — 배관: 본체 · 관 / 세월교: 유출 구조", None]
assert 'import { attachHoverInfo } from "@ui/ui_template_hover_info";' in UI
assert "`td.${LAMP_CLASS}`," in UI # 램프 칸에만
locale = (ROOT / "ui_template" / "ui_template_locale_b4.ts").read_text(encoding="utf-8")
assert 'B08_Quantity_Summary_LampTip: ["구조물 조합 미연결",' in locale
def test_미지정_열_없음_줄_값_그대로():
assert 'key: "unassigned"' not in UI and "B08_Quantity_Summary_Unassigned" not in UI
assert 'L("B08_Quantity_Summary_Unlinked")' not in UI # 측점 「· 미연결」 글 지움
assert "frozen: 1," in UI
rows = re.search(r"rows: stations\.map\(\(station, i\) => \(\{(.*?)\}\)\),", UI, re.S)
assert rows and [s.strip() for s in rows.group(1).strip().split("\n")] == [
"station: labels[i],",
"...values.get(stationKey(station)),",
]
def test_램프_경고_노랑_토큰():
css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text(encoding="utf-8")
m = re.search(
r'\[data-b08-summary="table"\] td\.' + re.escape(_lamp_class()) + r"::before\s*\{([^}]*)\}",
css,
)
assert m
assert re.search(r"background:\s*var\(--color-warning\)", m.group(1))
assert "border-radius: 50%" in m.group(1)
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8")
assert len(re.findall(r"--color-warning:\s*#", theme)) >= 2 # 밝음 · 어두움
@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""PLAN 199-1 — B08 표 칸 최대 높이 = 브라우저 창 아래 끝까지.
Node 헬퍼(`helper_ui_fill_viewport.cjs`)가 `ui_template_data_table.ts` 의 창 끝까지 셈을 가짜 DOM 에서 돌리고,
여기서 옵션 · 셈 · 창 크기 바뀜 · 합계 줄 고정 · B08 적용을 판정함.
"""
import json
import os
import re
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as f:
return f.read()
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_ui_fill_viewport.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_최대_높이_창_기준_셈(r):
assert r["clamp"] == 900 - 200 - 68
assert r["small"] == 120 # 아주 작은 창에서도 머리 + 몇 줄은 남김
assert r["below"] == r["belowExpected"] == 80 # 뒤 형제 + 윗대 아래 여백
def test_붙은_뒤에_셈(r):
assert r["beforeAttach"] is None # 문서에 붙기 전엔 안 건드림
assert r["observed"] # 칸 + 문서 본문(body) 둘 다 관찰
assert r["first"] == f"{900 - 200 - 80}px" # 620
assert r["fits"] >= 1
def test_창_크기_바뀌면_다시_셈(r):
assert r["afterResize"] == f"{600 - 200 - 80}px" # 320
def test_스크롤_위치와_무관(r):
assert r["afterScroll"] == r["afterResize"]
def test_아래_요소가_늘면_그만큼_줄어듦(r):
assert r["afterNoteGrow"] == f"{600 - 200 - 140}px" # 260
def test_칸이_떨어지면_창_청취_해제(r):
assert r["listenersAfterDetach"] == 0
def test_공용_표_옵션과_연결():
ts = _read("ui_template", "ui_template_data_table.ts")
assert re.search(r"fillViewport\?: boolean", ts)
assert re.search(r"if \(opts\.fillViewport\) fillViewport\(root, \(\) => draw\(false\)\)", ts)
def test_합계_줄_고정_그대로():
css = _read("ui_template", "ui_template_data_table.css")
assert re.search(r"tfoot[^{]*\{[^}]*position:\s*sticky[^}]*bottom:\s*0", css, re.S)
assert "max-height: calc(100vh - 260px)" in css # 옵션 안 준 표의 기본값은 그대로
@pytest.mark.parametrize(
"name, count",
[
("B08_Quantity_UI_Summary.ts", 1),
("B08_Quantity_UI_Earthwork.ts", 1),
("B08_Quantity_UI_Earthwork_Sheets.ts", 2),
("B08_Quantity_UI_Material.ts", 1),
],
)
def test_B08_표_모두_적용(name, count):
src = _read("B08_Quantity", name)
assert src.count("fillViewport: true") == count == src.count("createDataTable({")
+5 -1
View File
@@ -28,7 +28,11 @@ TABLE = {
"옹벽",
["wall_form", "length_m", "height_m", "before_m", "after_m"],
),
"berm": ("gbermdetail", "상세 내용", ["width_m", "depth_m"]), # 높이 칸 숨김 · 깊이(178-2)
"berm": (
"gbermdetail",
"상세 내용",
["width_m"],
), # 높이 · 깊이 칸 숨김(깊이 = 횡단도 ▲▼ · 194-1)
}
SOIL_FORMS = ["돌쌓기(찰)", "돌쌓기(메)", "콘크리트", "돌망태", "통나무·목재틀", "바자"]
@@ -96,5 +96,5 @@ def test_집계표는_공용_표():
# PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님)
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8")
assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui
assert "frozen: hasMissing ? 2 : 1" in ui and 'empty: ""' in ui and "onRowClick" in ui
assert "frozen: 1," in ui and 'empty: ""' in ui and "onRowClick" in ui
assert 'document.createElement("table")' not in ui
+61
View File
@@ -10,6 +10,7 @@
* 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
* 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로
* 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0)
* ========================================================================== */
import "./ui_template_data_table.css";
@@ -62,6 +63,8 @@ export interface DataTableOptions {
rowClass?: (index: number) => string | null | undefined;
/** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */
cellClass?: (index: number, key: string) => string | null | undefined;
/** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */
fillViewport?: boolean;
}
export interface DataTable {
@@ -240,6 +243,63 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
/** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */
const OVER = 40;
/* ── 창 아래 끝까지(PLAN 199) — 칸 최대 높이 = 창 높이 − 칸 위쪽 자리 − 아래 자리 ────────────
* 아래 자리 = 칸 뒤 형제 요소 높이 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백.
* 줄이 적으면 내용만큼 · 창 크기 · 위아래 요소가 바뀌면 다시 셈 · 칸이 문서에서 떨어지면 스스로 멈춤.
* ------------------------------------------------------------------------------------------ */
/** 칸이 이보다 작아지지 않게(아주 작은 창에서 머리만 남는 것 방지) */
const MIN_HEIGHT = 120;
/** 창 기준 최대 높이(px) — `top` = 문서 맨 위에서 칸 위까지 · `below` = 칸 아래로 남겨야 할 자리 */
export function fillHeight(viewport: number, top: number, below: number): number {
return Math.max(MIN_HEIGHT, Math.floor(viewport - top - below));
}
const px = (v: string): number => parseFloat(v) || 0;
/** 칸 아래로 문서 끝까지 차지하는 자리 */
export function spaceBelow(el: HTMLElement): number {
let sum = 0;
for (let node: HTMLElement = el; node.parentElement; node = node.parentElement) {
const parent = node.parentElement;
const own = getComputedStyle(node);
const around = getComputedStyle(parent);
const gap = px(around.rowGap);
for (let s = node.nextElementSibling; s; s = s.nextElementSibling) {
const h = s.getBoundingClientRect().height;
if (h) sum += h + gap;
}
sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth);
}
return sum;
}
export function fillViewport(el: HTMLElement, onFit?: () => void): () => void {
let seen = false; // 한 번이라도 문서에 붙었는지 — 붙었다가 떨어지면 멈춤
let observer: ResizeObserver | null = null;
const fit = (): void => {
if (!el.isConnected) {
if (seen) stop();
return;
}
seen = true;
const top = el.getBoundingClientRect().top + window.scrollY;
el.style.maxHeight = `${fillHeight(window.innerHeight, top, spaceBelow(el))}px`;
onFit?.();
};
const stop = (): void => {
window.removeEventListener("resize", fit);
observer?.disconnect();
};
window.addEventListener("resize", fit);
// 칸이 붙을 때 · 줄이 늘 때 · 위아래 요소가 바뀔 때(문서 높이) 다시 셈
observer = new ResizeObserver(fit);
observer.observe(el);
observer.observe(document.body);
return stop;
}
export function createDataTable(opts: DataTableOptions): DataTable {
const root = document.createElement("div");
root.className = "ui-data-table";
@@ -287,6 +347,7 @@ export function createDataTable(opts: DataTableOptions): DataTable {
state.onRowClick?.(Number(row.dataset.i));
});
draw(true);
if (opts.fillViewport) fillViewport(root, () => draw(false));
return {
root,
setRows(rows, total) {
+2
View File
@@ -472,7 +472,9 @@ export const ui_locales_b2 = {
"측구를 생성한 경우에만 형식을 선택할 수 있습니다.",
"Available only when a ditch is created.",
],
B06_Notice_FillSlope: ["성토 5m↑", "Fill slope >5m"],
B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"],
B06_Notice_Berm: ["소단 검토", "Berm review"],
B06_Cross_Columns: ["가로 개수", "Per row"],
B06_Design_Detail: ["상세", "Detail"],
B06_Design_Paved_Suggested: [
+1 -1
View File
@@ -63,7 +63,7 @@ export const ui_locales_b4 = {
],
B08_Quantity_Summary_Total: ["계", "Total"],
B08_Quantity_Summary_Unlinked: ["미연결", "Unlinked"],
B08_Quantity_Summary_Unassigned: ["미지정", "Unassigned"],
B08_Quantity_Summary_LampTip: ["구조물 조합 미연결", "Structure combination unlinked"],
B08_Quantity_Link_Title: ["조합 연결", "Link combinations"],
B08_Quantity_Link_Body: ["본체", "Body"],
B08_Quantity_Link_Linked: ["연결됨", "Linked"],