Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_8
This commit is contained in:
@@ -475,16 +475,11 @@ export async function mountCrossView(
|
||||
handoff,
|
||||
sectionDetail,
|
||||
{
|
||||
// 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15).
|
||||
focusStation: (stationId) => {
|
||||
structuresPanel.prefer(handoff?.structureId ?? null);
|
||||
sectionView.focusStation(stationId);
|
||||
structuresPanel.prefer(null);
|
||||
// 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1)
|
||||
const sid = handoff?.structureId;
|
||||
if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid))
|
||||
structuresPanel.selectStructure(sid);
|
||||
},
|
||||
// 넘겨받은 구조물 id 가 먼저(52-15) · 폼은 id 로 직접(53-1) · 측점 없는 구조물은 카드 · 스크롤 없음(307)
|
||||
focusStation: (stationId) =>
|
||||
structuresPanel.focusPicked(handoff?.structureId ?? null, () =>
|
||||
sectionView.focusStation(stationId),
|
||||
),
|
||||
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
|
||||
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
|
||||
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
|
||||
|
||||
@@ -31,7 +31,7 @@ registerPanelSection({
|
||||
views: ["profile"],
|
||||
order: 30,
|
||||
build: (ctx, view) => {
|
||||
const frame = createSectionFrame(view, "계획노선");
|
||||
const frame = createSectionFrame(view, "계획노선", true); // 311 기본 접힘
|
||||
frame.body.append(part<RoutePart>(ctx, "route").plannedRouteButton);
|
||||
return frame.root;
|
||||
},
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface RevetOffsetControl {
|
||||
highlightFor: (section: CrossSection) => RevetPick | null;
|
||||
/** `ownerM` = 폼에 올릴 시설 자리를 누른 쪽이 앎(물넘이 콘크리트 · 294-7) — 없으면 카드로 셈. */
|
||||
select: (chainageM: number, key: RevetPick | null, ownerM?: number) => void;
|
||||
/** 고른 「관」이 물넘이 콘크리트인가 — 그 카드는 콘크리트만 켬 · 관 그림은 안 켬(307). */
|
||||
shapePicked?: () => boolean;
|
||||
/** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */
|
||||
onSelectionChange?: () => void;
|
||||
/** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
* 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) ·
|
||||
* 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로.
|
||||
* 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님.
|
||||
* 맹암거(306-2) = 안쪽 판정면은 맨 아래(겹친 띠 · 구조물이 위) · 테두리 판정(투명 굵은 선)이 면 판정 맨 위.
|
||||
* ========================================================================== */
|
||||
|
||||
import { thickenDown } from "./B06_Section_UI_Cross_Shape_Pick";
|
||||
|
||||
/** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */
|
||||
const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"];
|
||||
/** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */
|
||||
const LINE_HIT_CLASS = "b06-chart__design-hit";
|
||||
/** 맹암거 몸(그림) · 테두리 판정 클래스(306-2). */
|
||||
const UNDERDRAIN_GROUP = "b06-chart__underdrain";
|
||||
const UNDERDRAIN_BODY = "b06-chart__underdrain-body";
|
||||
const EDGE_CLASS = "b06-chart__shape-hit--edge";
|
||||
/** 끌기로 볼 움직임(px) — 형상 고름(`SHAPE_DRAG_PX`)과 같은 값. */
|
||||
const DRAG_PX = 3;
|
||||
|
||||
@@ -33,13 +40,36 @@ export function pointsArea(points: string | null): number {
|
||||
/** 판정면을 넓이 차례로 다시 세우고 보라 선 판정을 맨 위로 · 끌기 누름을 막음. */
|
||||
export function stackPickTargets(layer: SVGElement): void {
|
||||
const kids = [...layer.children];
|
||||
// 맹암거 판정면 = 형상 고름이 몸 둘레(`thickenDown`)로 꽂은 것
|
||||
const bodies = new Set(
|
||||
kids
|
||||
.filter((el) => el.classList.contains(UNDERDRAIN_GROUP))
|
||||
.flatMap((group) => [...group.children])
|
||||
.filter((el) => el.classList.contains(UNDERDRAIN_BODY))
|
||||
.map((el) => thickenDown(el.getAttribute("points") ?? "")),
|
||||
);
|
||||
const inners = kids.filter(
|
||||
(el) =>
|
||||
el.classList.contains("b06-chart__shape-hit") && bodies.has(el.getAttribute("points") ?? ""),
|
||||
);
|
||||
const edges = inners.map((inner) => {
|
||||
layer.prepend(inner);
|
||||
const edge = inner.cloneNode() as SVGElement;
|
||||
edge.classList.add(EDGE_CLASS);
|
||||
edge.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
inner.dispatchEvent(new MouseEvent("click")); // 고름은 안쪽 판정면 창구 하나
|
||||
});
|
||||
return edge;
|
||||
});
|
||||
const surfaces = kids
|
||||
.filter((el) => SURFACE_HIT_CLASSES.some((name) => el.classList.contains(name)))
|
||||
.filter((el) => !inners.includes(el))
|
||||
.map((el) => ({ el, area: pointsArea(el.getAttribute("points")) }))
|
||||
.sort((a, b) => b.area - a.area)
|
||||
.map(({ el }) => el);
|
||||
const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS));
|
||||
layer.append(...surfaces, ...lines);
|
||||
layer.append(...surfaces, ...edges, ...lines);
|
||||
let down: { x: number; y: number } | null = null;
|
||||
layer.addEventListener(
|
||||
"pointerdown",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
* 노면선 보라 판정 띠(굵기 10 · 위아래 5) 바깥 아래를 누르면 형상(294-7).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RevetHighlightSetter } from "./B06_Section_UI_Cross_Culvert";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 관 지점이 아닌 구조물(맹암거) 형상 누름 — 카드 밖(구조물 목록)이 id 로 고름(288-2). */
|
||||
export const SHAPE_PICK_EVENT = "b06-structure-shape-pick";
|
||||
@@ -105,12 +107,26 @@ export function shapePickRoute(
|
||||
};
|
||||
}
|
||||
|
||||
/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴). */
|
||||
/** 카드 「관」 강조를 관 그림 · 물넘이 콘크리트로 가름(307) — 콘크리트를 고름(`concrete`) = 콘크리트만 ·
|
||||
* 관을 고름 = 관만(관 카드에 걸친 물넘이포장 콘크리트를 눌러도 관 형상은 안 켬). */
|
||||
export function splitPipePick(
|
||||
base: RevetHighlightSetter,
|
||||
setShape: (active: boolean) => void,
|
||||
concrete: () => boolean,
|
||||
): RevetHighlightSetter {
|
||||
return (key) => {
|
||||
const shape = key === "pipe" && concrete();
|
||||
base(shape ? null : key);
|
||||
setShape(shape);
|
||||
};
|
||||
}
|
||||
|
||||
/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴 · 맹암거). 물넘이
|
||||
* 콘크리트(`pipe-…`)는 형상을 직접 누른 「관」 고름만 켬 — 측점 길(측점선 · 알약 · 목록)은 안 켬(307). */
|
||||
export function highlightShapes(root: ParentNode, structureId: string | null): void {
|
||||
for (const shape of root.querySelectorAll<SVGElement>("[data-structure-id]")) {
|
||||
shape.classList.toggle(
|
||||
"is-active",
|
||||
!!structureId && shape.getAttribute("data-structure-id") === structureId,
|
||||
);
|
||||
const id = shape.getAttribute("data-structure-id") ?? "";
|
||||
if (id.startsWith("pipe-")) continue;
|
||||
shape.classList.toggle("is-active", !!structureId && id === structureId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,39 @@
|
||||
* · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
|
||||
* · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도.
|
||||
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
|
||||
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(흙막이 돌 그리기 `drawStones`).
|
||||
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(306-1) — 켜마다 반 칸 엇갈리고 이웃과 겹친 돌 ·
|
||||
* 기울기를 번갈아 서로 교차 · 그림 전용(누름 안 받음 · CSS `.b06-chart__underdrain`).
|
||||
* 높이가 비면 그리지 않음(수치를 지어내지 않음).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign } from "./B06_Section_Api_Fetch";
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { drawStones } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import { cell, courses, type BandFrame } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import type { UnderdrainSpec } from "@util/common_util_structure_underdrain";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
let clipSeq = 0;
|
||||
/** 돌 한 칸(m · 보기용) · 이웃과 겹침 배수 · 번갈아 기울기(도). */
|
||||
const STONE_M = 0.25;
|
||||
const STONE_OVERLAP = 1.4;
|
||||
const STONE_TILT_DEG = 22;
|
||||
|
||||
/** 엇갈려 겹친 돌 무늬(306-1) — 켜(가로줄)마다 반 칸 밀고 · 돌은 칸보다 커서 이웃 · 위아래와 겹침 ·
|
||||
* 기울기를 번갈아 서로 교차. `frame` v = −0.5(노면) ~ 0.5(옵셋선). */
|
||||
export function drawCrossedStones(group: SVGElement, frame: BandFrame, heightM: number): void {
|
||||
const rows = courses(heightM, STONE_M);
|
||||
const count = courses(frame.spanM, STONE_M);
|
||||
const across = (heightM / rows) * STONE_OVERLAP;
|
||||
const along = (frame.spanM / count) * STONE_OVERLAP;
|
||||
for (let r = 0; r < rows; r += 1) {
|
||||
const shift = r % 2 ? 0 : 0.5;
|
||||
for (let i = 0; i + shift <= count; i += 1) {
|
||||
const tilt = (i + r) % 2 ? STONE_TILT_DEG : -STONE_TILT_DEG;
|
||||
const lean = { ...frame, leanDegrees: frame.leanDegrees + tilt };
|
||||
group.append(cell(lean, (i + shift) / count, (r + 0.5) / rows - 0.5, across, along, 0.45));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Edge = { offset_m: number; elevation_m: number };
|
||||
|
||||
@@ -120,7 +142,7 @@ export function appendUnderdrainOverlay(
|
||||
};
|
||||
const [ax, ay] = px(at(0, 0));
|
||||
const [bx, by] = px(at(1, 0));
|
||||
drawStones(
|
||||
drawCrossedStones(
|
||||
stones,
|
||||
{
|
||||
spanM: Math.hypot(hi - lo, grade * (hi - lo)),
|
||||
@@ -128,7 +150,6 @@ export function appendUnderdrainOverlay(
|
||||
point: (u, v) => px(at(u, v)),
|
||||
leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI,
|
||||
},
|
||||
false,
|
||||
height,
|
||||
);
|
||||
group.append(body, clip, stones);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
appendFordSurfaceDropPlan,
|
||||
} from "./B06_Section_UI_Cross_Ford_Pavement";
|
||||
import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { attachShapePick, shapePickRoute } from "./B06_Section_UI_Cross_Shape_Pick";
|
||||
import { attachShapePick, shapePickRoute, splitPipePick } from "./B06_Section_UI_Cross_Shape_Pick";
|
||||
import { stackPickTargets } from "./B06_Section_UI_Cross_Pick_Order";
|
||||
import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain";
|
||||
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
|
||||
@@ -232,9 +232,11 @@ export function createCrossSectionCard(
|
||||
},
|
||||
selectCard: () => onSelect(section.station_id),
|
||||
});
|
||||
const shapePicked = (): boolean => revetOffset?.shapePicked?.() ?? false;
|
||||
const toggleRevet = (key: RevetPick, ownerM?: number): void => {
|
||||
const wasSelected = isSelected;
|
||||
activeRevet = activeRevet === key ? null : key;
|
||||
// 같은 「관」이라도 콘크리트 ↔ 관 그림을 바꿔 누르면 풀지 않고 옮김(307)
|
||||
activeRevet = activeRevet === key && (ownerM !== undefined) === shapePicked() ? null : key;
|
||||
showRevetControl(activeRevet !== null);
|
||||
// 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를
|
||||
// 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다.
|
||||
@@ -595,12 +597,9 @@ export function createCrossSectionCard(
|
||||
revetOffset && shapePickRoute(svg, (at) => toggleRevet("pipe", at)),
|
||||
);
|
||||
if (setShapeActive) {
|
||||
const baseSetActive = setRevetActive;
|
||||
setRevetActive = (key) => {
|
||||
baseSetActive(key);
|
||||
setShapeActive(key === "pipe");
|
||||
};
|
||||
setShapeActive(highlightRevet === "pipe");
|
||||
// 「관」 = 관 그림 또는 콘크리트 하나만(307 — 연동 관 카드 콘크리트 누름에 관 형상 안 켬)
|
||||
setRevetActive = splitPipePick(setRevetActive, setShapeActive, shapePicked);
|
||||
setRevetActive(highlightRevet);
|
||||
}
|
||||
// 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2).
|
||||
const lineDesign = section.design;
|
||||
|
||||
@@ -50,7 +50,7 @@ registerPanelSection({
|
||||
views: ["cross"],
|
||||
order: 20,
|
||||
build: (ctx, view) => {
|
||||
const frame = createSectionFrame(view, L("B06_View_Title"));
|
||||
const frame = createSectionFrame(view, L("B06_View_Title"), true); // 311 기본 접힘
|
||||
frame.body.append(part<CrossViewPart>(ctx, "crossView").row);
|
||||
return frame.root;
|
||||
},
|
||||
|
||||
@@ -183,8 +183,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
);
|
||||
const revetShifts = revetSession.values;
|
||||
/** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편:
|
||||
* 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */
|
||||
let revetSelection: { at: string; key: RevetPick } | null = null;
|
||||
* 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). `shape` = 「관」이 물넘이 콘크리트(307). */
|
||||
let revetSelection: { at: string; key: RevetPick; shape?: boolean } | null = null;
|
||||
const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`;
|
||||
|
||||
function loadRevetShifts(): void {
|
||||
@@ -268,14 +268,17 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
if (!owner || !here || Math.abs(here.chainage_m - owner.chainage_m) > 0.01) return null;
|
||||
return wallStandsAt(owner, section, revetSelection.key) ? revetSelection.key : null;
|
||||
},
|
||||
select: (chainageM, key) => {
|
||||
revetSelection = key ? { at: chainageM.toFixed(2), key } : null;
|
||||
select: (chainageM, key, ownerM) => {
|
||||
// 「관」 = 물넘이 콘크리트 — 형상 누름(ownerM) · 관 없는 카드 · 걸친 관 그림은 안 켬(307)
|
||||
const shape = key === "pipe" && (ownerM !== undefined || !sectionAt(chainageM)?.culvert);
|
||||
revetSelection = key ? { at: chainageM.toFixed(2), key, shape } : null;
|
||||
// 벽을 고르면 같은 측점 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
|
||||
const { ford, box } = bodyControls;
|
||||
if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);
|
||||
if (key && box.control.selectedFor(chainageM)) box.control.select(chainageM, null);
|
||||
revetOffsetControl.onSelectionChange?.();
|
||||
},
|
||||
shapePicked: () => revetSelection?.shape === true,
|
||||
syncApplied: (chainageM, role, applied) => {
|
||||
// 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지.
|
||||
const key = revetKey(chainageM, role);
|
||||
|
||||
@@ -87,7 +87,7 @@ export interface B06StructuresPanelDeps {
|
||||
wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null;
|
||||
/** 측점 간격(m) — 측점번호+잔여거리 환산용. */
|
||||
stationInterval: () => number;
|
||||
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다(revealOnly = 고르지 않고 스크롤만 · 177-4). */
|
||||
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다(revealOnly = 고르지도 스크롤도 않음 · 측점 없는 구조물 · 307). */
|
||||
focusChainage: (chainageM: number, revealOnly?: boolean) => void;
|
||||
/** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */
|
||||
releaseCard?: () => void;
|
||||
@@ -130,8 +130,11 @@ export interface B06StructuresPanel {
|
||||
preferred: () => string | null;
|
||||
/** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */
|
||||
selectStructure: (structureId: string) => void;
|
||||
/** 측점 없는 관(물넘이포장) 하나를 폼에 올림 — 카드 안 고름 · 스크롤만 · 알약 · 고름 값에 알약 id(요구 178). */
|
||||
/** 측점 없는 관 하나를 폼에 올림 — 카드 안 고름 · 스크롤 없음 · 알약 · 고름 값에 알약 id(요구 178 · 307). */
|
||||
selectPipeAt: (chainageM: number) => void;
|
||||
/** 넘김값(B05 알약 · 3D · 지도)의 카드 고름 — `focus` 가 그 카드를 고름 · 넘긴 구조물이 먼저(52-15) ·
|
||||
* 폼은 id 로 직접 엶(53-1) · 측점 없는 구조물은 카드 고름 · 스크롤 없이 폼만(요구 178 · 307). */
|
||||
focusPicked: (structureId: string | null, focus: () => void) => void;
|
||||
/** 배수유역도가 관 초안에 채운 빈 월류 높이(126-1)를 목록 · 폼에 받는다(298-1). */
|
||||
adoptFordHeights: () => void;
|
||||
/** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */
|
||||
@@ -224,7 +227,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
}
|
||||
currentChainageM = null; // 폼 = 이 구조물 — 옛 관을 붙든 채 남지 않게(165-2)
|
||||
// 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다.
|
||||
// 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 그 카드로 스크롤만 · 폼 유지(177-4)
|
||||
// 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 스크롤 없음 · 폼 유지(177-4 · 307)
|
||||
// 측점 없는 구조물(제 측점을 안 심음)도 같은 길 — 근처 카드 측점선을 켜지 않음(요구 178)
|
||||
const revealOnly = !coverable(structure) || !plantsStation(structure, markTypes);
|
||||
const id = structure.structure_id ?? null;
|
||||
@@ -586,6 +589,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
},
|
||||
preferred: () => preferredId,
|
||||
selectStructure: (structureId) => section.selectById(structureId),
|
||||
focusPicked: (structureId, focus) => {
|
||||
const known = structures.find((entry) => entry.structure_id === structureId);
|
||||
if (!known || plantsStation(known, markTypes)) {
|
||||
preferredId = structureId;
|
||||
focus();
|
||||
preferredId = null;
|
||||
}
|
||||
if (known) section.selectById(structureId);
|
||||
},
|
||||
showAtChainage: (chainageM) => {
|
||||
currentChainageM = null;
|
||||
const selection: DesignSelection = { chainageM, structureId: preferredId };
|
||||
@@ -616,7 +628,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
);
|
||||
if (!pipe) return;
|
||||
const id = `pipe-${pipe.chainage_m.toFixed(2)}`;
|
||||
deps.focusChainage(pipe.chainage_m, true); // 카드 안 고름 · 스크롤만 — 옛 카드 · 측점선 풂(177-4 길)
|
||||
deps.focusChainage(pipe.chainage_m, true); // 카드 안 고름 · 스크롤 없음 — 옛 카드 · 측점선 풂(177-4 길 · 307)
|
||||
currentChainageM = pipe.chainage_m;
|
||||
refreshSpecDefaults(pipe.chainage_m);
|
||||
section.selectPipeByChainage(pipe.chainage_m);
|
||||
|
||||
@@ -90,7 +90,7 @@ export function wireStructureSelection(
|
||||
ownerM ?? (key === "chute" ? chainageM : ownerChainageOf(chainageM, key ?? undefined));
|
||||
// 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1)
|
||||
if (key) markWallSelecting(owner);
|
||||
origRevet(chainageM, key);
|
||||
origRevet(chainageM, key, ownerM); // ownerM = 콘크리트 형상 누름 표식(307)
|
||||
if (key) remember?.(owner, key);
|
||||
if (key) panel.showAtChainage(owner);
|
||||
else if (!nested) {
|
||||
|
||||
@@ -244,8 +244,11 @@ export function createSectionView(
|
||||
let spanMarks: ReadonlyArray<StructureInstance> = [];
|
||||
let spanTypes: ReadonlyArray<StructureType> = [];
|
||||
let spanMarkId: string | null = null;
|
||||
/** 고른 구조물(알약 · 목록 · 폼) — 물넘이포장 · 맹암거 형상 강조(287-5 · 288-2). */
|
||||
/** 고른 구조물(알약 · 목록 · 폼) — 맹암거 형상 강조(288-2 · 물넘이 콘크리트는 「관」 고름 몫 · 307). */
|
||||
let pickedMarkId: string | null = null;
|
||||
/** 포장층을 직접 누른 노면공 — 그 포장층만 강조 · 측점 길(알약 · 목록) 고름은 안 켬(307). */
|
||||
let paveShapeId: string | null = null;
|
||||
let paveShapeTurn = false;
|
||||
const inSpan = (section: CrossSection, mark: StructureInstance | undefined): boolean => {
|
||||
const { start_m: start, end_m: end } = mark ?? {};
|
||||
if (start == null || end == null || start === end) return false;
|
||||
@@ -261,7 +264,7 @@ export function createSectionView(
|
||||
? spanMarks.find((entry) => entry.structure_id === spanMarkId)
|
||||
: undefined;
|
||||
const span = inSpan(section, mark);
|
||||
const pave = span && isPavement(spanMarkId);
|
||||
const pave = span && isPavement(spanMarkId) && paveShapeId === spanMarkId;
|
||||
card.classList.toggle("b06-cross-card--span", span);
|
||||
highlightPavement(card, pave ? mark : undefined);
|
||||
highlightShapes(card, pickedMarkId);
|
||||
@@ -430,7 +433,14 @@ export function createSectionView(
|
||||
const pave = pavementAt(event.target, spanMarks);
|
||||
if (!pave?.structure_id || !isPavement(pave.structure_id)) return;
|
||||
event.stopPropagation();
|
||||
pavementSelectListener?.(pave.structure_id);
|
||||
paveShapeTurn = true;
|
||||
try {
|
||||
pavementSelectListener?.(pave.structure_id);
|
||||
} finally {
|
||||
paveShapeTurn = false;
|
||||
}
|
||||
paveShapeId = pave.structure_id;
|
||||
syncSpanHighlights();
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -605,19 +615,20 @@ export function createSectionView(
|
||||
);
|
||||
}
|
||||
},
|
||||
revealStation(stationId) {
|
||||
// 노면공 고름 = 카드 하나 안 고름 · 옛 카드는 리스너 없이 풂(폼 유지) · 그 카드로 스크롤만(177-4)
|
||||
revealStation() {
|
||||
// 측점 없는 구조물(노면공 등) 고름 = 카드 하나 안 고름 · 옛 카드는 리스너 없이 풂(폼 유지) ·
|
||||
// 강조할 횡단도가 없어 스크롤도 안 함(177-4 · 요구 178 · 307)
|
||||
const previous = selectedStationId;
|
||||
selectedStationId = null;
|
||||
activeAreaKey = null;
|
||||
markCard(previous);
|
||||
profile.showCard(null);
|
||||
revealCard(stationId, "smooth");
|
||||
},
|
||||
selectMark: (structureId, span = true) => {
|
||||
// span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 포장층은 테두리가 아니라 늘 켬(165-2)
|
||||
// span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 범위 테두리는 늘(165-2) · 포장층은 누름만(307)
|
||||
spanMarkId = span || isPavement(structureId) ? structureId : null;
|
||||
pickedMarkId = structureId;
|
||||
if (!paveShapeTurn) paveShapeId = null; // 포장층 강조 = 포장층 누름 턴만(307)
|
||||
syncSpanHighlights();
|
||||
profile.selectMark(structureId);
|
||||
},
|
||||
|
||||
@@ -248,7 +248,7 @@ export function createSectionProfile(
|
||||
edit.selectStructure(hit.structure_id);
|
||||
return;
|
||||
}
|
||||
// 측점 없는 관(물넘이포장) = 카드 안 고름 · 그 자리로 스크롤만 · 그 관 폼 · 알약만(요구 178)
|
||||
// 측점 없는 관 = 카드 안 고름 · 스크롤 없음 · 그 관 폼 · 알약만(요구 178 · 307)
|
||||
if (edit?.selectPipe && !plantsStation(hit, types)) {
|
||||
edit.selectPipe(structureAnchorM(hit));
|
||||
return;
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface SectionViewController {
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
|
||||
focusStation: (stationId: string | null) => void;
|
||||
/** 카드를 고르지 않고 스크롤만 — 고른 카드는 알림 없이 풂(노면공 목록 고름 · 177-4). */
|
||||
/** 카드를 고르지 않음 · 스크롤 없음 — 고른 카드는 알림 없이 풂(측점 없는 구조물 고름 · 177-4 · 307). */
|
||||
revealStation: (stationId: string) => void;
|
||||
/** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름).
|
||||
* `span` = 구간형 범위 안 카드 전부 테두리(기본) · 끄면 카드는 고른 측점 하나(137-4). */
|
||||
|
||||
@@ -267,7 +267,25 @@
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* 맹암거(288-1): 노면 아래 돌 무늬 형상 */
|
||||
/* 맹암거(288-1): 노면 아래 돌 무늬 형상 · 그림 전용 — 누름은 판정면(306-2) */
|
||||
.b06-chart__underdrain {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 엇갈려 겹친 돌(306-1) — 맹암거 색 · 밝음 · 어두움 모두 읽힘 */
|
||||
.b06-chart__underdrain .b06-chart__culvert-stone {
|
||||
stroke: #3aa0a8;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* 맹암거 테두리 판정(306-2) — 면 판정 맨 위 · 안쪽 판정면은 맨 아래 */
|
||||
.b06-chart__shape-hit.b06-chart__shape-hit--edge {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 8;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
|
||||
.b06-chart__underdrain-body {
|
||||
fill: color-mix(in srgb, #3aa0a8 16%, transparent);
|
||||
stroke: #3aa0a8;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
buildCrossLayoutControls,
|
||||
buildDesignInfoPanel,
|
||||
buildDrawingSidePanel,
|
||||
foldedGroups,
|
||||
isCrossSheet,
|
||||
} from "./B07_DesignDetail_UI_Panels";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
@@ -91,6 +92,8 @@ let reopenCrossSheet: number | undefined;
|
||||
|
||||
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
// 311 다시 세우면 옛 목록 접힘 그대로 — 기다리기 전에 읽음(처음 들어오면 B07 목록이 없어 기본값)
|
||||
const folded = foldedGroups(root);
|
||||
let workflowState: WorkflowState | undefined;
|
||||
let drawings: DesignDrawingItem[] = [];
|
||||
let drawingError: string | undefined;
|
||||
@@ -565,6 +568,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
: undefined,
|
||||
crossLayoutSlot,
|
||||
folded,
|
||||
);
|
||||
drawingListEl = drawingPanel;
|
||||
const confirmActions = document.createElement("div");
|
||||
@@ -608,7 +612,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
layout.root.classList.add("b07-design-layout");
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
// 페이지 진입 시 첫 도면(종단도)을 자동 선택 — 빈 CAD 화면 방지.
|
||||
// 페이지 진입 시 첫 도면(표지 · 311)을 자동 선택 — 빈 CAD 화면 방지.
|
||||
// CAD가 아직 준비 전이면 cad.load 가 붙들고 있다가 ready 때 보낸다.
|
||||
if (drawings.length > 0) {
|
||||
const crossSheets = drawings.filter(isCrossSheet);
|
||||
@@ -618,7 +622,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
const reopen = drawings.findIndex((item) => item.id === reopenId);
|
||||
reopenDrawingId = undefined;
|
||||
reopenCrossSheet = undefined;
|
||||
const first = Math.max(reopen, 0);
|
||||
// 311 기억한 장이 없으면 표지
|
||||
const cover = drawings.findIndex((item) => item.kind === "cover");
|
||||
const first = reopen >= 0 ? reopen : Math.max(cover, 0);
|
||||
void openTab(drawings[first]).then(() => {
|
||||
// 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다.
|
||||
void prefetchAllDrawings();
|
||||
|
||||
@@ -39,6 +39,19 @@ export const DRAWING_GROUPS: readonly {
|
||||
{ label: "토적도(유토곡선)", kind: "mass_haul" },
|
||||
];
|
||||
|
||||
/** 311 기본 접힘 묶음 — 그 밖 묶음(토적도 등)은 기본 펼침. */
|
||||
const FOLDED_KINDS = new Set(["plan", "longitudinal", "cross"]);
|
||||
|
||||
/** 311 지금 목록의 묶음 접힘(kind → 접힘) — 화면을 다시 세울 때 이어 받음 · B07 목록이 없으면 빈 것. */
|
||||
export function foldedGroups(root: ParentNode): Map<string, boolean> {
|
||||
return new Map(
|
||||
[...root.querySelectorAll<HTMLElement>(".b07-drawing-group")].map((section) => [
|
||||
section.dataset.kind ?? "",
|
||||
section.classList.contains("is-collapsed"),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** B06 확정 산출물 기반 도면 목록 패널. */
|
||||
export function buildDrawingSidePanel(
|
||||
drawings: DesignDrawingItem[],
|
||||
@@ -48,7 +61,10 @@ export function buildDrawingSidePanel(
|
||||
plan?: PlanManageControls,
|
||||
/** 횡단도 묶음 맨 아래에 붙일 자리(153-1 장 배치 칸) — 내용은 화면 조립이 도면을 열 때마다 채움. */
|
||||
crossTools?: HTMLElement,
|
||||
/** 311 다시 세울 때 옛 접힘(`foldedGroups`) — 없는 묶음은 기본값. */
|
||||
folded = new Map<string, boolean>(),
|
||||
): HTMLDivElement {
|
||||
const isFolded = (kind: string): boolean => folded.get(kind) ?? FOLDED_KINDS.has(kind);
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b07-drawing-list";
|
||||
const heading = document.createElement("div");
|
||||
@@ -95,7 +111,12 @@ export function buildDrawingSidePanel(
|
||||
// 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다.
|
||||
const plans = drawings.filter((item) => item.kind === "plan");
|
||||
panel.append(
|
||||
buildPlanSection(plans, drawingButton, plan ? () => openPlanManager(plan) : undefined),
|
||||
buildPlanSection(
|
||||
plans,
|
||||
drawingButton,
|
||||
plan ? () => openPlanManager(plan) : undefined,
|
||||
isFolded("plan"),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -133,7 +154,7 @@ export function buildDrawingSidePanel(
|
||||
continue;
|
||||
}
|
||||
// 컨테이너 = 좌측 패널 공용 카드(269 · B05/B06 「구조물 배치」 양식).
|
||||
const section = createPanelCard(`${group.label} ${items.length}`);
|
||||
const section = createPanelCard(`${group.label} ${items.length}`, isFolded(group.kind ?? ""));
|
||||
section.classList.add("b07-drawing-group");
|
||||
if (group.kind) section.dataset.kind = group.kind;
|
||||
if (cross) {
|
||||
|
||||
@@ -257,9 +257,10 @@ export function buildPlanSection(
|
||||
items: DesignDrawingItem[],
|
||||
makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement,
|
||||
onManage?: () => void,
|
||||
collapsed = false,
|
||||
): HTMLElement {
|
||||
// 컨테이너 = 좌측 패널 공용 카드(269)
|
||||
const section = createPanelCard(`평면도 ${items.length}`);
|
||||
const section = createPanelCard(`평면도 ${items.length}`, collapsed);
|
||||
section.classList.add("b07-drawing-group", "b07-plan");
|
||||
section.dataset.kind = "plan";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* B09 원가계산 — 탭 줄 · 안내 글. 색은 테마 변수만(밝음 · 어두움 같이). */
|
||||
|
||||
.b09-estimation {
|
||||
--b09-hint-lines: 3; /* 203 안내 글 영역 줄 수 — 이 값만 바꿈(높이 · 스크롤 · 표 칸 같이 맞음) */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
@@ -57,7 +58,7 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 202-1 안내 줄 = 표 · 카드 판 아래(묶음 탭은 한 줄 줄임표 · 전문 = 공용 호버) */
|
||||
/* 202-1 안내 줄 = 표 · 카드 판 아래 · 203 줄 수 변수만큼 보임 · 넘치면 영역 안 스크롤 */
|
||||
.b09-estimation .ui-data-table + .ui-general-block__empty,
|
||||
.b09-estimation .ui-group-cards + .ui-general-block__empty {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
@@ -65,9 +66,8 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
|
||||
|
||||
.b09-estimation__line-hint {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-height: calc(var(--b09-hint-lines) * 1lh);
|
||||
overflow-y: auto;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 창 = 열 제목(머리 여러 줄 그대로) + 강조 범위 줄 전체 · 표 짜기 = `…_UI_Builders`(화면과 같은 옵션)
|
||||
* 강조 범위 = 설계내역서 그 줄 + 자손 · 묶음 탭 호표 구간 · 그 밖 한 줄(하이퍼링크 강조와 같음)
|
||||
* 자리 = 누른 줄 아래(모자라면 위 · 화면 안) · 너비 = 표 너비(최대 화면 90%) · 넘치면 창 안 스크롤
|
||||
* 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · Esc · × = 맨 위 하나 · 탭 바꿈 · 주소 바뀜 = 모두
|
||||
* 창 안 링크 줄 누름 = 이어진 창(겹쳐 위에 · 그 줄 곁) · × = 그 창부터 위로 · 탭 바꿈 · 주소 바뀜 = 모두
|
||||
* Esc · 맨 위 창 밖 누름(아래 창 · 빈 곳 · 톡) = 모두(308) — 그 누름은 닫기만(아래 화면에 안 넘김)
|
||||
* 머리 [탭으로] = 하이퍼링크처럼 옮김(모두 닫음) · 창 안 = 보기 전용(고칠 칸 · 메뉴 없음)
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -67,6 +68,8 @@ export function popupPlace(
|
||||
/** 열린 창(아래 → 위) */
|
||||
const stack: HTMLElement[] = [];
|
||||
let listening = false;
|
||||
/** 바깥 누름으로 닫은 뒤 따라오는 click · 우클릭 메뉴를 먹음(308) */
|
||||
let eat = false;
|
||||
|
||||
/** from 번째 창부터 위로 모두 닫음(0 = 모두) */
|
||||
export function closePopups(from = 0): void {
|
||||
@@ -78,17 +81,37 @@ export const popupCount = (): number => stack.length;
|
||||
function listen(): void {
|
||||
if (listening) return;
|
||||
listening = true;
|
||||
// Esc = 맨 위 하나(뒤 화면의 Esc 풂보다 먼저 · 창 있을 때만 가로챔)
|
||||
// Esc = 모두(뒤 화면의 Esc 풂보다 먼저 · 창 있을 때만 가로챔)
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key !== "Escape" || !stack.length) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closePopups(stack.length - 1);
|
||||
closePopups();
|
||||
},
|
||||
true,
|
||||
);
|
||||
// 맨 위 창 밖 누름 = 모두 닫음 · 그 누름은 아래로 안 넘김(308)
|
||||
window.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
eat = !!stack.length && !stack[stack.length - 1].contains(event.target as Node);
|
||||
if (!eat) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closePopups();
|
||||
},
|
||||
true,
|
||||
);
|
||||
const swallow = (event: Event): void => {
|
||||
if (!eat) return;
|
||||
eat = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
window.addEventListener("click", swallow, true);
|
||||
window.addEventListener("contextmenu", swallow, true);
|
||||
window.addEventListener("hashchange", () => closePopups());
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const makeTable = (extra?: (index: number) => string | null): DataTable => {
|
||||
const made = createDataTable({
|
||||
...tableOptionsOf(spec, tab),
|
||||
fillViewport: true, // 203 표 칸 = 창 끝 − 안내 글 영역(팝업 · 인쇄는 그대로)
|
||||
rowClass: extra
|
||||
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
|
||||
: spec.rowClass,
|
||||
@@ -230,7 +231,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
);
|
||||
return made;
|
||||
};
|
||||
const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 236-1 안내 글 = 한 줄
|
||||
const hint = spec.hint ? lineHint(note(spec.hint)) : null; // 203 안내 글 = 줄 수 변수만큼 · 넘치면 스크롤
|
||||
// 171-3 묶음 탭(일위대가표 · 중기사용료 · 단가산출근거) = 묶음 카드 기본 · 좌측 하단 스위치로 표(304-1)
|
||||
if (isGroupTab(tab)) {
|
||||
return void panel.replaceChildren(
|
||||
@@ -259,7 +260,10 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
if (range) unpickOnRelease(table.root);
|
||||
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
|
||||
if (level) bars.setTools(level.tools);
|
||||
panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []));
|
||||
// 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임
|
||||
if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`;
|
||||
const foot = hint ?? level?.guide;
|
||||
panel.replaceChildren(table.root, ...(foot ? [foot] : []));
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
|
||||
};
|
||||
|
||||
@@ -332,7 +336,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
});
|
||||
reset.dataset.b09BillReset = "";
|
||||
// 카드 = 좌측 패널 공용 카드(269 · 「구조물 배치」 양식)
|
||||
const section = createPanelCard("데이터 점검");
|
||||
const section = createPanelCard("데이터 점검", true); // 311 기본 접힘
|
||||
section.append(refresh, check.list);
|
||||
// 303-1 · 304-1 하단 고정 줄 — [박스보기|호표보기] · [하이퍼링크|팝업] · 초안 단추 · [?][저장][내역 초기화]
|
||||
const actions = createDock([save, reset], () => isGroupTab(current) && show(current));
|
||||
|
||||
@@ -228,7 +228,7 @@ function numberFormat(spec: TabTable, key: string): (v: number | null | undefine
|
||||
|
||||
/* ── 그리기 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 안내 글 = 한 줄(줄임표) · 잘렸을 때만 마우스 올리면 전문(공용 호버 185-3) */
|
||||
/** 안내 글 = 줄 수 변수(203 `--b09-hint-lines`)만큼 · 넘치면 영역 안 스크롤 · 가로로 잘렸을 때만 전문(공용 호버 185-3) */
|
||||
export function lineHint(hint: HTMLElement | null): HTMLElement | null {
|
||||
if (!hint || hint.classList.contains("b09-estimation__line-hint")) return hint;
|
||||
hint.classList.add("b09-estimation__line-hint");
|
||||
|
||||
@@ -160,7 +160,7 @@ export function createSchedulePanel(user: () => Row, setUser: (next: Row) => voi
|
||||
holidayCaption.textContent = "공휴일 넣기(그 밖 법정공휴일은 늘 넣음)";
|
||||
holidayBox.append(holidayCaption, ...checks);
|
||||
|
||||
const section = createPanelCard("공기 옵션");
|
||||
const section = createPanelCard("공기 옵션", true); // 311 기본 접힘
|
||||
section.dataset.b09SchedulePanel = "";
|
||||
section.style.display = "none"; // 원천(서버 공기 자료)이 오면 보임 · hidden 은 공용 카드 flex 가 덮음
|
||||
section.append(
|
||||
|
||||
@@ -231,3 +231,5 @@
|
||||
200. (원문 · 10-13) 1. B06 횡단도의 내부의 구조물 그림 수정 및 선택 하이라이트 개선 - 맹암거의 내부 형상에 의해 다른 면적 선택이 안됨. 우선순위 재확인하고 내부 형상이 돌 모양이 교차한 형상으로 변경 - 물넘이 포장과 배관이 인접하여 포장이 배수관 횡단도로 연동되어 넘어간경우 노면의 콘크리트를 선택하면 배관의 노면 콘크리트 선택까진는 좋음. 하지만 배관 형상까지 하이라이트 됨. - 노면공과 물넘이 포장의 경우 종단 측점 세로선, 알약, 좌측패널 횡단배수 측점과 영역을 선택하면 컨크리트 구조물까지 선택됨. 이건 이전에 선택이 어려울때 남은 잔재임. 구조물 형상선택에 따른 좌측 패널 하위컨테이너 활성화는 별개로 볼것. 다른 구조물들과 동일한 로직이 됨. (대신 수정이 복잡하게 엮여있으니 정밀히 분석 후 반영) 2. 깃처리 테스트라는 브랜치는 없음 각 워크트리에는 하나의 브랜치 존재. 일단 sub1까지 일단 전역 싱크하자. sub1이 더 수정했으니 포함하여 싱크 3. 측점이 존재하지 않는 구조물의 경우에는 하이라이트할 횡단도는 없으니 횡단 하이라이트 이동은 안되게(기 요청사항이었음. 로직 확인)
|
||||
201. (원문 · 10-13) 4. B09의 팝업기능으로 연결정보를 보는 복수 모달창에서 esc키를 누르면 전체 모달창이 닫게하고 최종모달창을 기준으로 다른 곳을 누를 경우에도 닫히게 반영 5. 계획서는 내가 지웠음
|
||||
202. (원문 · 10-13) 6. B05/B06의 종단그래프 내부 누가토량의 알약과 폰트 색상을 테마에 맞추되 잘보이게 변경하고 알약을 그래프 영역 내부에서 이동이 되게 반영
|
||||
203. (원문 · 10-13) 7. b09 페이지의 탭의 상세페이지에서 안내문구 영역이 있음. 영역을 텍스트 3줄정도만 보이게 하고 이후는 스크롤로 처리, 테이블을 더 넓게 사용하도록 조절, 이때 제일 하단행 고정기능이나 행이 적을 경우 합계행이 끌려올라가야하니 조심. 이때 안내문구 영역도 끌려올라가야함. 안내문구 영역은 2줄로 바꿀수도 있으니 참고하여 코드 반영
|
||||
204. (원문 · 10-13) 8. B09의 좌측페널 데이터 점검 및 공기 옵션 컨티이너의 기본값은 접힘상태. 9. B07 좌측패널의 평면도, 종단도, 횡단도는 접힘상태가 기본값이며 B07 초기 접근시 보여주는 페이지는 표지로 변경 10. B06 좌측 패널의 횡단보기 설정 기본값 접힘. 11. B05 좌측 패널의 컨테이너 이름을 계획노선 컨테이너의 기본값은 접힘상태로 변경 12. exp_sub1_device 브랜치 삭제
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/* PLAN 306 단위검증 헬퍼 — 맹암거 돌 무늬(306-1) · 고르기 차례(306-2).
|
||||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
|
||||
* 짝 = `test_306_underdrain_pick.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();
|
||||
const load = (file) => {
|
||||
if (cache.has(file)) return cache.get(file);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(file, box.exports);
|
||||
const near = (m) => {
|
||||
if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`));
|
||||
if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`));
|
||||
return new Proxy({}, { get: () => () => {} });
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, near);
|
||||
return box.exports;
|
||||
};
|
||||
|
||||
/* ── 흉내 DOM ─────────────────────────────────────────────────────────── */
|
||||
class Node {
|
||||
constructor(tag, name = tag) {
|
||||
this.tag = tag;
|
||||
this.name = name;
|
||||
this.attrs = {};
|
||||
this.children = [];
|
||||
this.listeners = [];
|
||||
const self = this;
|
||||
this.classList = {
|
||||
contains: (c) => (self.attrs.class ?? "").split(/\s+/).includes(c),
|
||||
add: (c) => (self.attrs.class = `${self.attrs.class ?? ""} ${c}`.trim()),
|
||||
};
|
||||
}
|
||||
setAttribute(k, v) {
|
||||
this.attrs[k] = String(v);
|
||||
}
|
||||
getAttribute(k) {
|
||||
return this.attrs[k] ?? null;
|
||||
}
|
||||
hasAttribute(k) {
|
||||
return k in this.attrs;
|
||||
}
|
||||
/** `[속성]` 꼴만 — 형상 고름이 쓰는 것. */
|
||||
querySelectorAll(sel) {
|
||||
const attr = /^\[([\w-]+)\]$/.exec(sel)[1];
|
||||
return this.children.flatMap((k) => [
|
||||
...(k.hasAttribute(attr) ? [k] : []),
|
||||
...k.querySelectorAll(sel),
|
||||
]);
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => {
|
||||
const at = this.children.indexOf(k);
|
||||
if (at >= 0) this.children.splice(at, 1);
|
||||
this.children.push(k);
|
||||
});
|
||||
}
|
||||
prepend(...kids) {
|
||||
kids.forEach((k) => {
|
||||
const at = this.children.indexOf(k);
|
||||
if (at >= 0) this.children.splice(at, 1);
|
||||
});
|
||||
this.children.unshift(...kids);
|
||||
}
|
||||
insertBefore(k, ref) {
|
||||
const at = this.children.indexOf(k);
|
||||
if (at >= 0) this.children.splice(at, 1);
|
||||
const to = ref ? this.children.indexOf(ref) : -1;
|
||||
this.children.splice(to >= 0 ? to : this.children.length, 0, k);
|
||||
}
|
||||
cloneNode() {
|
||||
const copy = new Node(this.tag, `${this.name}+edge`);
|
||||
copy.attrs = { ...this.attrs };
|
||||
return copy;
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
this.listeners.push({ type, fn });
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
this.listeners.filter((l) => l.type === event.type).forEach((l) => l.fn(event));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) };
|
||||
globalThis.MouseEvent = class {
|
||||
constructor(type) {
|
||||
this.type = type;
|
||||
this.clientX = 0;
|
||||
this.clientY = 0;
|
||||
}
|
||||
stopPropagation() {}
|
||||
};
|
||||
const clickEvent = () => ({ type: "click", clientX: 0, clientY: 0, stopPropagation() {} });
|
||||
|
||||
const draw = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Underdrain.ts"));
|
||||
const shapePick = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts"));
|
||||
const order = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Pick_Order.ts"));
|
||||
const out = {};
|
||||
|
||||
/* ── 306-1 돌 무늬 ────────────────────────────────────────────────────── */
|
||||
// 288 시험과 같은 단면 — 좌 절토 · 우 성토 · 노면 6 m · 암거 높이 1 m · 1 m = 20 px
|
||||
const design = {
|
||||
road_edges: {
|
||||
left: { offset_m: 3, elevation_m: 100.15 },
|
||||
right: { offset_m: -3, elevation_m: 99.85 },
|
||||
},
|
||||
design_line: [
|
||||
{ offset_m: 6, elevation_m: 102.9 },
|
||||
{ offset_m: 3, elevation_m: 100.15 },
|
||||
{ offset_m: -3, elevation_m: 99.85 },
|
||||
{ offset_m: -7, elevation_m: 97.2 },
|
||||
],
|
||||
};
|
||||
const PPM = 20;
|
||||
const X = (o) => 300 - o * PPM;
|
||||
const Y = (e) => 2100 - e * PPM;
|
||||
const svg = new Node("g", "layer");
|
||||
const group = draw.appendUnderdrainOverlay(
|
||||
svg,
|
||||
{ structure_id: "ud-1", chainage_m: 40, height_m: 1, slope_pct: 5 },
|
||||
design,
|
||||
X,
|
||||
Y,
|
||||
);
|
||||
const stones = group.children.find((k) => k.tag === "g").children;
|
||||
const rotate = (s) => /rotate\(([-\d.]+) ([-\d.]+) ([-\d.]+)\)/.exec(s.attrs.transform);
|
||||
out.stones = stones.map((s) => {
|
||||
const [, deg, cx, cy] = rotate(s).map(Number);
|
||||
return {
|
||||
cls: s.attrs.class,
|
||||
deg,
|
||||
cx,
|
||||
cy,
|
||||
w: Number(s.attrs.width),
|
||||
h: Number(s.attrs.height),
|
||||
};
|
||||
});
|
||||
out.groupClass = group.attrs.class;
|
||||
|
||||
/* ── 306-2 고르기 차례 ────────────────────────────────────────────────── */
|
||||
const poly = (name, cls, pts) => {
|
||||
const n = new Node("polygon", name);
|
||||
n.setAttribute("class", cls);
|
||||
n.setAttribute("points", pts.map((p) => p.join(",")).join(" "));
|
||||
return n;
|
||||
};
|
||||
const box = (x0, y0, x1, y1) => [
|
||||
[x0, y0],
|
||||
[x1, y0],
|
||||
[x1, y1],
|
||||
[x0, y1],
|
||||
];
|
||||
// 꽂힌 옛 차례(카드와 같음) — 지면선 · 면적 띠 · 포장 · 맹암거 무리 · 관 판정면 · 보라 선
|
||||
const layer = new Node("g", "plot");
|
||||
const band = new Node("g", "band");
|
||||
band.setAttribute("class", "b06-chart__area b06-chart__area--fill");
|
||||
band.append(poly("bandPoly", "", box(300, 100, 460, 160)));
|
||||
const body = group.children.find((k) => k.attrs.class === "b06-chart__underdrain-body");
|
||||
const bodyPts = body.attrs.points.split(" ").map((p) => p.split(",").map(Number));
|
||||
layer.append(
|
||||
band,
|
||||
group,
|
||||
poly("bigPipe", "b06-chart__culvert-revet-hit", box(100, 111, 600, 117)), // 맹암거(≈2600 px²)보다 넓음
|
||||
poly("smallPipe", "b06-chart__culvert-revet-hit", box(340, 110, 346, 114)),
|
||||
);
|
||||
const picked = [];
|
||||
shapePick.attachShapePick(layer, (id) => picked.push(id));
|
||||
layer.children.find((k) => k.classList.contains("b06-chart__shape-hit")).name = "underdrain";
|
||||
const line = new Node("polyline", "road");
|
||||
line.setAttribute("class", "b06-chart__design-hit");
|
||||
line.setAttribute("points", `${X(3)},${Y(100.15)} ${X(-3)},${Y(99.85)}`);
|
||||
layer.append(line);
|
||||
// 옛 차례(306 전) — 형상 판정면이 넓이 순으로 면 판정 사이에 섬
|
||||
const SURF = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"];
|
||||
const area = (n) => order.pointsArea(n.attrs.points ?? null);
|
||||
const oldKids = [...layer.children];
|
||||
const oldOrder = [
|
||||
...oldKids.filter((k) => !SURF.some((c) => k.classList.contains(c)) && k !== line),
|
||||
...oldKids
|
||||
.filter((k) => SURF.some((c) => k.classList.contains(c)))
|
||||
.sort((a, b) => area(b) - area(a)),
|
||||
line,
|
||||
];
|
||||
order.stackPickTargets(layer);
|
||||
out.order = layer.children.map((k) => k.name);
|
||||
|
||||
/* 누른 자리 → 맨 위에서 받는 것(흉내) — 그림 전용(맹암거 무리)은 안 받음 */
|
||||
const inside = (pts, [x, y]) =>
|
||||
pts.reduce((inn, [ax, ay], i) => {
|
||||
const [bx, by] = pts[(i + 1) % pts.length];
|
||||
return ay > y !== by > y && x < ax + ((y - ay) * (bx - ax)) / (by - ay) ? !inn : inn;
|
||||
}, false);
|
||||
const segGap = ([px, py], [ax, ay], [bx, by]) => {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy || 1)));
|
||||
return Math.hypot(px - ax - t * dx, py - ay - t * dy);
|
||||
};
|
||||
const ptsOf = (n) => n.attrs.points.split(" ").map((p) => p.split(",").map(Number));
|
||||
const ringGap = (pts, p) => Math.min(...pts.map((a, i) => segGap(p, a, pts[(i + 1) % pts.length])));
|
||||
function receiver(kids, p) {
|
||||
for (const k of [...kids].reverse()) {
|
||||
if (k.classList.contains("b06-chart__underdrain")) continue; // pointer-events: none
|
||||
if (k.classList.contains("b06-chart__design-hit")) {
|
||||
const pts = ptsOf(k);
|
||||
if (segGap(p, pts[0], pts[1]) <= 5) return k.name;
|
||||
} else if (k.classList.contains("b06-chart__shape-hit--edge")) {
|
||||
if (ringGap(ptsOf(k), p) <= 4) return k.name;
|
||||
} else if (k.classList.contains("b06-chart__area")) {
|
||||
if (k.children.some((c) => inside(ptsOf(c), p))) return k.name;
|
||||
} else if (k.attrs.points && inside(ptsOf(k), p)) return k.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 맹암거 몸 = (240,97) (360,103) (392.7,124.6) (263.1,118.2) · 띠 = x 300 부터 · 큰 관 = y 111~117
|
||||
const PROBES = {
|
||||
bandInUnderdrain: [330, 107], // 띠 · 맹암거 겹침(관 밖)
|
||||
pipeInUnderdrain: [330, 114], // 큰 관 · 띠 · 맹암거 겹침
|
||||
smallPipe: [343, 112],
|
||||
underdrainOnly: [280, 107], // 맹암거만
|
||||
border: [320, 121], // 아랫변 위 · 띠 안
|
||||
road: [300, 100], // 노면 = 맹암거 윗변 · 보라 선
|
||||
};
|
||||
out.probesNew = Object.fromEntries(
|
||||
Object.entries(PROBES).map(([k, p]) => [k, receiver(layer.children, p)]),
|
||||
);
|
||||
out.probesOld = Object.fromEntries(
|
||||
Object.entries(PROBES).map(([k, p]) => [k, receiver(oldOrder, p)]),
|
||||
);
|
||||
// 테두리 누름 = 안쪽 판정면 창구 → 맹암거 id
|
||||
const edge = layer.children.find((k) => k.classList.contains("b06-chart__shape-hit--edge"));
|
||||
edge.listeners.filter((l) => l.type === "click").forEach((l) => l.fn(clickEvent()));
|
||||
out.picked = picked;
|
||||
out.body = bodyPts;
|
||||
out.edgeCount = layer.children.filter((k) =>
|
||||
k.classList.contains("b06-chart__shape-hit--edge"),
|
||||
).length;
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,134 @@
|
||||
/* 307 B06 고름 강조 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로.
|
||||
* ① 「관」 setter 가름(splitPipePick) ② 측점 길 형상 강조(highlightShapes) ③ 벽 고름 콘크리트 표식(Station_Controls).
|
||||
*/
|
||||
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();
|
||||
const load = (file) => {
|
||||
if (cache.has(file)) return cache.get(file);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(file, box.exports);
|
||||
const near = (m) => {
|
||||
if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`));
|
||||
if (m.endsWith(".json")) return require(path.join(path.dirname(file), m));
|
||||
if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`));
|
||||
return new Proxy({}, { get: () => () => {} });
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, near);
|
||||
return box.exports;
|
||||
};
|
||||
const memory = new Map();
|
||||
globalThis.sessionStorage = {
|
||||
getItem: (k) => (memory.has(k) ? memory.get(k) : null),
|
||||
setItem: (k, v) => memory.set(k, String(v)),
|
||||
removeItem: (k) => memory.delete(k),
|
||||
};
|
||||
globalThis.window = { setTimeout, clearTimeout, sessionStorage: globalThis.sessionStorage };
|
||||
|
||||
const B = (f) => path.join(ROOT, "B06_Section", f);
|
||||
const shapePick = load(B("B06_Section_UI_Cross_Shape_Pick.ts"));
|
||||
|
||||
// ① 「관」 setter — 콘크리트 고름 = 콘크리트만 · 관 고름 = 관만 · 다른 벽 = 그대로
|
||||
const split = (concrete, key) => {
|
||||
const calls = { base: [], shape: [] };
|
||||
const setter = shapePick.splitPipePick(
|
||||
(k) => calls.base.push(k),
|
||||
(on) => calls.shape.push(on),
|
||||
() => concrete,
|
||||
);
|
||||
setter(key);
|
||||
return calls;
|
||||
};
|
||||
|
||||
// ② 측점 길 형상 강조 — 맹암거는 id 대로 · 물넘이 콘크리트(pipe-…)는 안 건드림
|
||||
class Node {
|
||||
constructor(id, cls = "") {
|
||||
this.attrs = { "data-structure-id": id, class: cls };
|
||||
}
|
||||
getAttribute(k) {
|
||||
return this.attrs[k] ?? null;
|
||||
}
|
||||
get classList() {
|
||||
const names = () => (this.attrs.class ?? "").split(" ").filter(Boolean);
|
||||
return {
|
||||
contains: (c) => names().includes(c),
|
||||
toggle: (c, on) => {
|
||||
const rest = names().filter((n) => n !== c);
|
||||
this.attrs.class = (on ? [...rest, c] : rest).join(" ");
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const drain = new Node("ud-1");
|
||||
const concreteLit = new Node("pipe-90.00", "is-active"); // 형상 누름 「관」이 켠 콘크리트
|
||||
const concreteOff = new Node("pipe-90.00");
|
||||
const root = { querySelectorAll: () => [drain, concreteLit, concreteOff] };
|
||||
shapePick.highlightShapes(root, "pipe-90.00"); // 물넘이포장 측점 길(알약 · 목록 · 측점선) = 알약 id
|
||||
const byStationPath = {
|
||||
concreteOff: concreteOff.classList.contains("is-active"),
|
||||
concreteLitKept: concreteLit.classList.contains("is-active"),
|
||||
};
|
||||
shapePick.highlightShapes(root, "ud-1");
|
||||
const drainLit = drain.classList.contains("is-active");
|
||||
shapePick.highlightShapes(root, null); // 풂(Esc · 다른 것) — 콘크리트는 「관」 고름 풂이 끔
|
||||
const afterRelease = {
|
||||
drain: drain.classList.contains("is-active"),
|
||||
concreteLit: concreteLit.classList.contains("is-active"),
|
||||
};
|
||||
|
||||
// ③ 벽 고름 콘크리트 표식 — 관 카드(80) · 물넘이 제 카드(90 · 관 없음)
|
||||
const stations = load(B("B06_Section_UI_Page_Station_Controls.ts"));
|
||||
const sections = [
|
||||
{ station_id: "s80", chainage_m: 80, culvert: { inlet: {}, outlet: {} }, samples: [] },
|
||||
{ station_id: "s90", chainage_m: 90, ford_pavement: { chainage_m: 90 }, samples: [] },
|
||||
];
|
||||
let changes = 0;
|
||||
const controls = stations.createStationControls({
|
||||
sessionKey: () => null,
|
||||
refreshCard: () => {},
|
||||
detail: () => ({ cross_sections: sections }),
|
||||
crossHalfWidth: () => undefined,
|
||||
sampledHalfWidth: () => 20,
|
||||
ensureSampledWidth: async () => true,
|
||||
projectId: () => null,
|
||||
});
|
||||
const revet = controls.revetOffset;
|
||||
revet.onSelectionChange = () => (changes += 1);
|
||||
const flag = (chainageM, key, ownerM) => {
|
||||
revet.select(chainageM, key, ownerM);
|
||||
return {
|
||||
shape: revet.shapePicked(),
|
||||
selected: revet.selectedFor(sections[chainageM === 80 ? 0 : 1]),
|
||||
};
|
||||
};
|
||||
const revetCases = {
|
||||
pipeOnPipeCard: flag(80, "pipe"), // 관 그림 누름 = 관
|
||||
concreteOnPipeCard: flag(80, "pipe", 90), // 연동 관 카드 콘크리트 누름 = 콘크리트
|
||||
concreteOnOwnCard: flag(90, "pipe", 90), // 물넘이 제 카드 콘크리트 누름
|
||||
handoffOnOwnCard: flag(90, "pipe"), // 3D 넘김 「관」(관 없는 카드) = 콘크리트
|
||||
wall: flag(80, "inlet"),
|
||||
released: flag(80, null),
|
||||
changes: 0,
|
||||
};
|
||||
revetCases.changes = changes;
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
split: {
|
||||
concretePipe: split(true, "pipe"),
|
||||
pipePipe: split(false, "pipe"),
|
||||
concreteWall: split(true, "inlet"),
|
||||
none: split(false, null),
|
||||
},
|
||||
byStationPath,
|
||||
drainLit,
|
||||
afterRelease,
|
||||
revetCases,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,256 @@
|
||||
/* 311 좌측 패널 기본 접힘 헬퍼 — B05 「계획노선」 · B06 「횡단 보기 설정」(한 벌 패널 `design_panel`)과
|
||||
* B07 도면 목록(평면도 · 종단도 · 횡단도)을 가짜 DOM 위에 세워 첫 그림 접힘 · 편 뒤 다시 그림 유지를 본다.
|
||||
* 결과는 JSON 한 벌. 꼴은 `helper_design_view_parity.cjs` 와 같음.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
// ── 화면 그릇 흉내 — 요소 · 클래스 · 속성 · 글자 · 자식 ──────────────────────────
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag;
|
||||
this.children = [];
|
||||
this.parent = null;
|
||||
this.dataset = {};
|
||||
this.style = {};
|
||||
this.attrs = {};
|
||||
this._text = "";
|
||||
this._classes = new Set();
|
||||
this.hidden = false;
|
||||
this.disabled = false;
|
||||
this.title = "";
|
||||
this.listeners = {};
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...n) => n.forEach((x) => self._classes.add(x)),
|
||||
remove: (...n) => n.forEach((x) => self._classes.delete(x)),
|
||||
toggle: (n, f) => {
|
||||
const on = f === undefined ? !self._classes.has(n) : !!f;
|
||||
on ? self._classes.add(n) : self._classes.delete(n);
|
||||
return on;
|
||||
},
|
||||
contains: (n) => self._classes.has(n),
|
||||
};
|
||||
}
|
||||
set className(v) {
|
||||
this._classes = new Set(String(v).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this._classes].join(" ");
|
||||
}
|
||||
set textContent(v) {
|
||||
this._text = String(v);
|
||||
this.children.forEach((c) => (c.parent = null));
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
_adopt(kid) {
|
||||
const node =
|
||||
typeof kid === "string" ? Object.assign(new FakeNode("#text"), { _text: kid }) : kid;
|
||||
if (node.parent) node.parent.children = node.parent.children.filter((c) => c !== node);
|
||||
node.parent = this;
|
||||
return node;
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => this.children.push(this._adopt(k)));
|
||||
}
|
||||
prepend(...kids) {
|
||||
this.children.unshift(...kids.map((k) => this._adopt(k)));
|
||||
}
|
||||
insertBefore(node, ref) {
|
||||
const adopted = this._adopt(node);
|
||||
const at = this.children.indexOf(ref);
|
||||
if (at < 0) this.children.push(adopted);
|
||||
else this.children.splice(at, 0, adopted);
|
||||
}
|
||||
replaceChildren(...kids) {
|
||||
this.children = [];
|
||||
this.append(...kids);
|
||||
}
|
||||
setAttribute(k, v) {
|
||||
this.attrs[k] = String(v);
|
||||
}
|
||||
removeAttribute(k) {
|
||||
delete this.attrs[k];
|
||||
if (k === "title") this.title = "";
|
||||
}
|
||||
addEventListener(type, handler) {
|
||||
(this.listeners[type] ||= []).push(handler);
|
||||
}
|
||||
click() {
|
||||
if (this.disabled) return;
|
||||
(this.listeners.click || []).forEach((h) => h({ target: this }));
|
||||
}
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] ?? null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
const cls = selector.slice(1);
|
||||
const out = [];
|
||||
const walk = (n) =>
|
||||
n.children.forEach((k) => {
|
||||
if (k.classList && k.classList.contains(cls)) out.push(k);
|
||||
walk(k);
|
||||
});
|
||||
walk(this);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
// 공용 attachCollapsible 이 쓰는 closest(`.클래스` 하나)
|
||||
FakeNode.prototype.closest = function (selector) {
|
||||
for (let n = this; n; n = n.parent)
|
||||
if (n.classList && n.classList.contains(selector.slice(1))) return n;
|
||||
return null;
|
||||
};
|
||||
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
|
||||
/** 제목 누름 — 패널 뿌리에 걸린 공용 위임 손잡이로 올려 보냄(가짜 DOM 은 거품이 없음). */
|
||||
const press = (root, section) => {
|
||||
const target = section.querySelector(".ui-collapsible__title");
|
||||
(root.listeners.click || []).forEach((h) => h({ target }));
|
||||
};
|
||||
|
||||
const node = (tag = "div") => new FakeNode(tag);
|
||||
const STUBS = {
|
||||
"@ui/ui_template_locale": {
|
||||
ui_locales: new Proxy({}, { get: (_, k) => [String(k)] }),
|
||||
currentLanguageIndex: 0,
|
||||
},
|
||||
"@ui/ui_template_elements": {
|
||||
createButton: ({ label }) => Object.assign(node("button"), { _text: label }),
|
||||
createInputField: () => ({ root: node(), input: node("input") }),
|
||||
createSelectField: () => ({ root: node(), select: { value: "" } }),
|
||||
showToast: () => undefined,
|
||||
},
|
||||
"@ui/ui_template_checkbox": { createCheckbox: () => ({ root: node("label"), input: {} }) },
|
||||
"../B05_Profile/B05_Profile_UI_Structures_Fields": { stationFields: () => ({ wrap: node() }) },
|
||||
"./B07_DesignDetail_UI_PlanManage": { openPlanManager: () => undefined },
|
||||
"./B07_DesignDetail_Api_Fetch": {},
|
||||
"./B06_Section_UI_Page_Common": { L: (key) => key },
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request === "@ui/ui_template_collapsible")
|
||||
return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts"));
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const out = {};
|
||||
const titleOf = (n) => n.children.find((c) => c.tagName === "h3")?.textContent ?? "";
|
||||
const folds = (root) =>
|
||||
Object.fromEntries(
|
||||
root
|
||||
.querySelectorAll(".ui-collapsible")
|
||||
.map((s) => [titleOf(s), s.classList.contains("is-collapsed")]),
|
||||
);
|
||||
|
||||
// ── B05 · B06 한 벌 패널 ───────────────────────────────────────────────────────
|
||||
const design = loadTs(path.join(ROOT, "A00_Common", "design", "design_panel.ts"));
|
||||
loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Sections.ts"));
|
||||
loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Panel.ts"));
|
||||
const text = (t) => Object.assign(node(), { _text: t });
|
||||
const run = { run: () => undefined };
|
||||
const panel = design.createDesignPanel(
|
||||
{
|
||||
projectId: "p",
|
||||
parts: {
|
||||
structures: { root: node("section"), listRoot: node() },
|
||||
settings: { nodes: [text("a")] },
|
||||
route: {
|
||||
plannedRouteButton: text("plan"),
|
||||
selectedNodes: [text("sel")],
|
||||
bindSelectedRoot: () => undefined,
|
||||
},
|
||||
crossView: { row: text("cross-row") },
|
||||
standardSlot: text("std"),
|
||||
},
|
||||
dock: { reset: run, save: run, confirm: run, switchView: run },
|
||||
},
|
||||
"profile",
|
||||
);
|
||||
const byTitle = (title) =>
|
||||
panel.root.querySelectorAll(".ui-collapsible").find((s) => titleOf(s) === title);
|
||||
out.profileFirst = folds(panel.root);
|
||||
const route = byTitle("계획노선");
|
||||
press(panel.root, route); // 사용자가 폄(공용 attachCollapsible)
|
||||
panel.setView("cross");
|
||||
out.crossFirst = folds(panel.root);
|
||||
press(panel.root, byTitle("B06_View_Title"));
|
||||
panel.setView("profile");
|
||||
panel.setView("cross");
|
||||
out.afterRedraw = folds(panel.root);
|
||||
out.sameRouteNode = byTitle("계획노선") === route;
|
||||
|
||||
// ── B07 도면 목록 ─────────────────────────────────────────────────────────────
|
||||
const b07 = loadTs(path.join(ROOT, "B07_DesignDetail", "B07_DesignDetail_UI_Panels.ts"));
|
||||
const d = (id, kind) => ({ id, kind, label: id, confirmed: false });
|
||||
const drawings = [
|
||||
d("longitudinal", "longitudinal"),
|
||||
d("longitudinal_2", "longitudinal"),
|
||||
d("cross_1", "cross"),
|
||||
d("cross_2", "cross"),
|
||||
d("plan_a", "plan"),
|
||||
d("cover", "cover"),
|
||||
d("cross_standard", "cross_standard"),
|
||||
d("mass_haul", "mass_haul"),
|
||||
d("mass_haul_2", "mass_haul"),
|
||||
];
|
||||
const groups = (p) =>
|
||||
Object.fromEntries(
|
||||
p
|
||||
.querySelectorAll(".b07-drawing-group")
|
||||
.map((s) => [s.dataset.kind, s.classList.contains("is-collapsed")]),
|
||||
);
|
||||
const fresh = node();
|
||||
out.b07EntryFolded = [...b07.foldedGroups(fresh)];
|
||||
const first = b07.buildDrawingSidePanel(
|
||||
drawings,
|
||||
() => undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
b07.foldedGroups(fresh),
|
||||
);
|
||||
out.b07First = groups(first);
|
||||
const host = node();
|
||||
host.append(first);
|
||||
first
|
||||
.querySelectorAll(".b07-drawing-group")
|
||||
.find((s) => s.dataset.kind === "cross")
|
||||
.classList.remove("is-collapsed");
|
||||
first
|
||||
.querySelectorAll(".b07-drawing-group")
|
||||
.find((s) => s.dataset.kind === "mass_haul")
|
||||
.classList.add("is-collapsed");
|
||||
const again = b07.buildDrawingSidePanel(
|
||||
drawings,
|
||||
() => undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
b07.foldedGroups(host),
|
||||
);
|
||||
out.b07Redraw = groups(again);
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -106,7 +106,10 @@ def test_좌측_관_상자_강조_스크롤() -> None:
|
||||
def test_벽_본체_동시_고름_금지_그대로() -> None:
|
||||
"""107-1 — 관도 같은 고름 저장소라 세월교 · 박스 고름과 함께 서지 않음."""
|
||||
station = _src("B06_Section/B06_Section_UI_Page_Station_Controls.ts")
|
||||
assert "let revetSelection: { at: string; key: RevetPick } | null = null;" in station
|
||||
assert (
|
||||
"let revetSelection: { at: string; key: RevetPick; shape?: boolean } | null = null;"
|
||||
in station
|
||||
)
|
||||
assert (
|
||||
"if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);"
|
||||
in station
|
||||
|
||||
@@ -60,12 +60,12 @@ def test_범위_카드_테두리는_노면공도():
|
||||
|
||||
def test_스크롤만_길은_리스너를_안_거침():
|
||||
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
body = re.search(r"revealStation\(stationId\) \{(.*?)\n \},", view, re.S)
|
||||
body = re.search(r"revealStation\(\) \{(.*?)\n \},", view, re.S)
|
||||
assert body, "revealStation 없음"
|
||||
text = body.group(1)
|
||||
assert "selectedStationId = null;" in text
|
||||
assert "profile.showCard(null);" in text
|
||||
assert 'revealCard(stationId, "smooth");' in text
|
||||
assert "revealCard" not in text # 측점 없는 구조물 = 스크롤도 없음(307)
|
||||
assert "stationSelectListener" not in text # 폼을 닫지 않음
|
||||
cross = _read("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "if (revealOnly) sectionView.revealStation(nearest.station_id);" in cross
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""200-1 B09 박스보기 안내 글 줄임표 + 호버 · 카드 같은 가로 줄 높이 같게.
|
||||
|
||||
- 안내 글 = 줄임표 한 줄 · 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 · 자리 = 표 · 카드 판 아래(202-1 로 옮김)
|
||||
- 안내 글 = 203 줄 수 변수만큼(넘치면 스크롤) · 가로로 잘렸을 때만 공용 호버(`attachHoverInfo`)로 전문 · 자리 = 표 · 카드 판 아래(202-1 로 옮김)
|
||||
- 카드 격자 = 행 늘림(`align-items: stretch`) · 카드 = 세로 flex · 몸통이 늘어 합계 줄이 아래
|
||||
- 카드 제목 줄 · 합계 줄 바탕색(200-3)
|
||||
"""
|
||||
@@ -78,13 +78,12 @@ def test_안내_글_없으면_호버도_없음():
|
||||
assert out["n"] == 1 and out["hintLast"] is False and out["hover"] == 0
|
||||
|
||||
|
||||
def test_CSS_안내_글_줄임표_한_줄():
|
||||
def test_CSS_안내_글_줄_수_변수():
|
||||
page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
hint = _block(page, ".b09-estimation__line-hint")
|
||||
for need in (
|
||||
"overflow: hidden",
|
||||
"text-overflow: ellipsis",
|
||||
"white-space: nowrap",
|
||||
for need in ( # 203 한 줄 줄임표 → 줄 수 변수만큼 · 넘치면 스크롤
|
||||
"max-height: calc(var(--b09-hint-lines) * 1lh)",
|
||||
"overflow-y: auto",
|
||||
"min-width: 0",
|
||||
):
|
||||
assert need in hint, need
|
||||
|
||||
@@ -200,7 +200,9 @@ def test_셸_배선():
|
||||
"bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in src
|
||||
) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나)
|
||||
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
|
||||
assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in src
|
||||
assert (
|
||||
"panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in src
|
||||
) # 203 레벨 안내 + 탭 안내 = 한 영역
|
||||
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
|
||||
assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수
|
||||
assert "b09-estimation__level" not in lvl
|
||||
|
||||
@@ -106,9 +106,8 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
|
||||
def test_잇기_쉘_안내_줄_아래_도구_비움():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert (
|
||||
"panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []))"
|
||||
in shell
|
||||
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 안내 줄 = 맨 아래
|
||||
"panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell
|
||||
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래)
|
||||
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
|
||||
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
|
||||
assert "setTools: bars.setTools" in shell
|
||||
|
||||
@@ -21,7 +21,9 @@ SHELL = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
|
||||
|
||||
def test_묶음_이름_단추_글_설명_없음():
|
||||
assert 'createPanelCard("데이터 점검")' in SHELL and "원가 단가" not in SHELL # 269 공용 카드
|
||||
assert (
|
||||
'createPanelCard("데이터 점검", true)' in SHELL and "원가 단가" not in SHELL
|
||||
) # 269 공용 카드
|
||||
assert 'label: "업데이트",' in SHELL and "단가 갱신" not in SHELL
|
||||
# 옛 굳힌 시각 안내 글(made) 없음 · 묶음 = 제목 · [업데이트] · 연결 점검
|
||||
assert "made.textContent" not in SHELL and "b09-estimation__hint" not in SHELL
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""203 B09 탭 상세 안내 글 영역 = 줄 수 변수(`--b09-hint-lines` 기본 3)만큼 · 넘치면 영역 안 스크롤 · 표 칸 = 창 끝까지.
|
||||
|
||||
- CSS: 줄 수 변수 하나 · 안내 글 최대 높이 = 변수 × 줄 높이(1lh) · 세로 스크롤 · 한 줄 줄임표 걷음
|
||||
- Shell: 탭 표 = 공용 fillViewport(팝업 · 인쇄 표는 그대로) · 설계내역서 레벨 안내 + 탭 안내 = 한 영역
|
||||
- 공용 spaceBelow: 아래 형제 바깥 여백(B09 안내 글 margin-top)도 셈 — 빼면 페이지 스크롤이 생김
|
||||
- 실제 브라우저(정적 쪽 · 서버 불필요): 변수 3 · 2 × 줄 많음 · 적음 — 안내 글 높이 · 합계 줄 · 안내 글 자리 · 페이지 스크롤 0
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B09 = ROOT / "B09_Estimation"
|
||||
UI = ROOT / "ui_template"
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text("utf-8")
|
||||
|
||||
|
||||
def _block(css: str, selector: str) -> str:
|
||||
m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", css)
|
||||
assert m, selector
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_CSS_줄_수_변수_하나():
|
||||
css = _read(B09 / "B09_Estimation_UI_Page.css")
|
||||
assert re.search(r"--b09-hint-lines:\s*3;", _block(css, ".b09-estimation"))
|
||||
assert css.count("--b09-hint-lines:") == 1 # 고칠 자리 하나
|
||||
hint = _block(css, ".b09-estimation__line-hint")
|
||||
assert "max-height: calc(var(--b09-hint-lines) * 1lh)" in hint
|
||||
assert "overflow-y: auto" in hint
|
||||
for gone in ("white-space: nowrap", "text-overflow: ellipsis"):
|
||||
assert gone not in hint, gone
|
||||
|
||||
|
||||
def test_Shell_표_창_끝_안내_한_영역():
|
||||
shell = _read(B09 / "B09_Estimation_UI_Shell.ts")
|
||||
assert "fillViewport: true" in shell
|
||||
assert "panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell
|
||||
assert "hint.textContent = `${level.guide.textContent} · ${hint.textContent}`" in shell
|
||||
# 팝업 · 인쇄 표는 창 끝 셈 안 씀(팝업 = 창 안 max-height · 인쇄 = 펼침)
|
||||
assert "fillViewport" not in _read(B09 / "B09_Estimation_UI_Builders.ts")
|
||||
assert "fillViewport" not in _read(B09 / "B09_Estimation_UI_Popup.ts")
|
||||
|
||||
|
||||
# ── 실제 브라우저 ─────────────────────────────────────────────────────────────
|
||||
|
||||
TOKENS = (
|
||||
":root { --spacing-4: 4px; --spacing-8: 8px; --spacing-16: 16px; --spacing-24: 24px;"
|
||||
" --text-caption: 12px; --radius-lg: 8px; }"
|
||||
" html, body { margin: 0; line-height: 1.5; font-family: sans-serif; }"
|
||||
)
|
||||
LONG = " · ".join(["긴 안내 글 시험 문장"] * 40)
|
||||
SHORT = "줄을 누르면 단가를 낸 탭 · 줄로 이동"
|
||||
|
||||
|
||||
def _module_js() -> str:
|
||||
"""공용 표 모듈(TS) → 브라우저 ES 모듈(CSS import 뺌)."""
|
||||
src = UI / "ui_template_data_table.ts"
|
||||
code = (
|
||||
"const ts = require(process.argv[1]);"
|
||||
"const fs = require('fs');"
|
||||
"const out = ts.transpileModule(fs.readFileSync(process.argv[2], 'utf8'),"
|
||||
" { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 } });"
|
||||
"process.stdout.write(out.outputText);"
|
||||
)
|
||||
ts_path = ROOT / "config" / "node_modules" / "typescript"
|
||||
proc = subprocess.run(
|
||||
["node", "-e", code, str(ts_path), str(src)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return re.sub(r'^import\s+"[^"]+\.css";\s*$', "", proc.stdout, flags=re.M)
|
||||
|
||||
|
||||
MEASURE = """async ([rows, lines, text]) => {
|
||||
const { createDataTable } = window.dt;
|
||||
const body = document.querySelector('.b09-estimation');
|
||||
body.style.setProperty('--b09-hint-lines', String(lines));
|
||||
const panel = body.querySelector('.b09-estimation__panel');
|
||||
const table = createDataTable({
|
||||
columns: [{ key: 'name', label: '이름' }, { key: 'sum', label: '금액', kind: 'number' }],
|
||||
rows: Array.from({ length: rows }, (_, i) => ({ name: '줄 ' + i, sum: i })),
|
||||
total: { name: '계', sum: 1 },
|
||||
fillViewport: true,
|
||||
});
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'ui-general-block__empty b09-estimation__line-hint';
|
||||
hint.textContent = text;
|
||||
panel.replaceChildren(table.root, hint);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const t = table.root.getBoundingClientRect();
|
||||
const h = hint.getBoundingClientRect();
|
||||
const foot = table.root.querySelector('tfoot').getBoundingClientRect();
|
||||
return {
|
||||
vh: innerHeight,
|
||||
page: document.scrollingElement.scrollHeight,
|
||||
lh: parseFloat(getComputedStyle(hint).lineHeight),
|
||||
hintH: h.height,
|
||||
hintScrolls: hint.scrollHeight > hint.clientHeight,
|
||||
tableTop: t.top,
|
||||
tableBottom: t.bottom,
|
||||
tableScrolls: table.root.scrollHeight > table.root.clientHeight,
|
||||
maxH: parseFloat(table.root.style.maxHeight),
|
||||
hintTop: h.top,
|
||||
hintBottom: h.bottom,
|
||||
padB: parseFloat(getComputedStyle(body).paddingBottom),
|
||||
footBottom: foot.bottom,
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def page():
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
css = _read(UI / "ui_template_data_table.css") + _read(B09 / "B09_Estimation_UI_Page.css")
|
||||
html = (
|
||||
f"<!doctype html><html><head><style>{TOKENS}{css}</style></head><body>"
|
||||
"<div class='b09-estimation'><div class='b09-estimation__tabs'><button>탭</button></div>"
|
||||
"<div class='b09-estimation__panel'></div></div></body></html>"
|
||||
)
|
||||
js = _module_js()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
pg = browser.new_page(viewport={"width": 1280, "height": 900})
|
||||
pg.set_content(html)
|
||||
pg.add_script_tag(
|
||||
type="module",
|
||||
content=f"{js}\nwindow.dt = {{ createDataTable }};",
|
||||
)
|
||||
pg.wait_for_function("!!window.dt")
|
||||
yield pg
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lines", [3, 2])
|
||||
def test_줄_많음_표_창_끝_합계_줄_고정_안내_글_바닥(page, lines):
|
||||
out = page.evaluate(MEASURE, [300, lines, LONG])
|
||||
assert out["hintH"] == pytest.approx(lines * out["lh"], abs=0.5), out # 높이 = 줄 수 × 줄 높이
|
||||
assert out["hintScrolls"], out # 넘친 글 = 영역 안 스크롤
|
||||
assert out["tableScrolls"], out
|
||||
assert out["page"] == out["vh"], out # 페이지 스크롤 0
|
||||
assert out["hintBottom"] + out["padB"] == pytest.approx(out["vh"], abs=1.5), out # 창 끝까지 씀
|
||||
assert out["hintTop"] == pytest.approx(out["tableBottom"] + 8, abs=0.5), out # 표 바로 아래
|
||||
assert out["tableBottom"] - 30 < out["footBottom"] <= out["tableBottom"], (
|
||||
out
|
||||
) # 합계 줄 = 표 칸 바닥
|
||||
|
||||
|
||||
def test_줄_수_2_이면_표가_한_줄만큼_더_김(page):
|
||||
three = page.evaluate(MEASURE, [300, 3, LONG])
|
||||
two = page.evaluate(MEASURE, [300, 2, LONG])
|
||||
assert two["maxH"] - three["maxH"] == pytest.approx(three["lh"], abs=1), (two, three)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lines", [3, 2])
|
||||
def test_줄_적음_합계_줄_안내_글_끌려_올라옴(page, lines):
|
||||
out = page.evaluate(MEASURE, [3, lines, LONG])
|
||||
assert not out["tableScrolls"], out
|
||||
assert out["tableBottom"] - out["tableTop"] < out["maxH"] - 100, out # 내용만큼(창 끝 아님)
|
||||
assert out["hintTop"] == pytest.approx(out["tableBottom"] + 8, abs=0.5), out # 표 끝에 붙음
|
||||
assert out["hintBottom"] < out["vh"] - 300, out # 화면 바닥에 떨어져 있지 않음
|
||||
assert out["hintH"] == pytest.approx(lines * out["lh"], abs=0.5), out
|
||||
assert out["footBottom"] <= out["tableBottom"], out
|
||||
|
||||
|
||||
def test_짧은_글은_한_줄만(page):
|
||||
out = page.evaluate(MEASURE, [300, 3, SHORT])
|
||||
assert out["hintH"] == pytest.approx(out["lh"], abs=0.5), out
|
||||
assert not out["hintScrolls"], out
|
||||
assert out["page"] == out["vh"], out
|
||||
|
||||
|
||||
def test_spaceBelow_형제_바깥_여백_셈():
|
||||
src = _read(UI / "ui_template_data_table.ts")
|
||||
assert "px(m.marginTop) + px(m.marginBottom)" in src # 브라우저 배치는 위 시험이 잼
|
||||
@@ -85,7 +85,9 @@ console.log(JSON.stringify({
|
||||
def test_표_위_도구_줄_없음_쉘_배선():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "if (level) bars.setTools(level.tools);" in shell
|
||||
assert "panel.replaceChildren(table.root, ...(level ? [level.guide] : [])" in shell
|
||||
assert (
|
||||
"panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell
|
||||
) # 203 레벨 안내 + 탭 안내 = 한 영역
|
||||
assert "...(level ? [level] : [])" not in shell # 표 위 자리 없음
|
||||
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
lvl = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- 안내 글 낱말에 제작 · 셈 설명(ROUND · 「 = 」 · 장 번호 · M01 · 곱셈 나눗셈 기호)이 없음
|
||||
- 안내 글 한 조각 70 자 이하(긴 글은 줄임표 + 공용 호버)
|
||||
- B09 안내 글(lineHint) · 레벨 안내 · B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표)
|
||||
- B08 안내 줄(footNote) = 한 줄(nowrap · 줄임표) · B09 안내 글 = 203 줄 수 변수만큼(test_203)
|
||||
- 창 크기(1080 · 768 · 600)마다 한 줄 안내 + 표 칸 = 창 끝(199-1 fillViewport 셈)
|
||||
"""
|
||||
|
||||
@@ -108,12 +108,13 @@ def _block(css: str, selector: str) -> str:
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_B09_안내_글_한_줄():
|
||||
def test_B09_안내_글_줄_수_변수():
|
||||
css = _read(B09 / "B09_Estimation_UI_Page.css")
|
||||
block = _block(css, ".b09-estimation__line-hint")
|
||||
assert "white-space: nowrap" in block and "text-overflow: ellipsis" in block
|
||||
# 203 한 줄 줄임표 → 줄 수 변수만큼 · 넘치면 스크롤(test_203_b09_hint_lines)
|
||||
assert "max-height: calc(var(--b09-hint-lines) * 1lh)" in block and "overflow-y: auto" in block
|
||||
shell = _read(B09 / "B09_Estimation_UI_Shell.ts")
|
||||
assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 한 줄
|
||||
assert "lineHint(note(spec.hint))" in shell # 표 탭도 묶음 탭과 같은 꼴
|
||||
assert "export function lineHint" in _read(B09 / "B09_Estimation_UI_Tabs_Groups.ts")
|
||||
level = _read(B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts")
|
||||
assert 'guide.className = "ui-general-block__empty b09-estimation__line-hint"' in level
|
||||
|
||||
@@ -82,8 +82,8 @@ def test_공용_카드_함수():
|
||||
|
||||
|
||||
def test_B07_컨테이너_공용_카드_도면_단추_공용_꼴():
|
||||
assert "createPanelCard(`${group.label} ${items.length}`)" in B07_PANELS
|
||||
assert "createPanelCard(`평면도 ${items.length}`)" in B07_PLAN
|
||||
assert "createPanelCard(`${group.label} ${items.length}`, isFolded(" in B07_PANELS
|
||||
assert "createPanelCard(`평면도 ${items.length}`, collapsed)" in B07_PLAN
|
||||
for src in (B07_PANELS, B07_PLAN):
|
||||
assert "ui-collapsible ui-sidebar-section" not in src # 손으로 짠 카드 없음
|
||||
assert B07_PANELS.count('"b07-drawing-button ui-panel-btn"') == 2
|
||||
@@ -149,7 +149,7 @@ def test_B05_칸_틀_두_보기_모두_공용_양식():
|
||||
|
||||
def test_B08_B09_카드_공용_카드():
|
||||
assert "createPanelCard(L(title))" in B08_OPTIONS
|
||||
assert 'createPanelCard("데이터 점검")' in B09_SHELL
|
||||
assert 'createPanelCard("데이터 점검", true)' in B09_SHELL
|
||||
for src in (B08_OPTIONS, B09_SHELL):
|
||||
assert "ui-sidebar-card" not in src
|
||||
|
||||
|
||||
@@ -115,7 +115,8 @@ def test_형상_누름은_고름_끌기는_고름_아님(ts):
|
||||
|
||||
def test_형상_강조와_표식(ts):
|
||||
assert ts["activeAfterSet"] is True
|
||||
assert ts["activeAfterOther"] is False and ts["activeAfterSame"] is True
|
||||
# 물넘이 콘크리트는 「관」 고름(형상 누름) 몫 — 측점 길 강조(highlightShapes)가 끄지도 켜지도 않음(307)
|
||||
assert ts["activeAfterOther"] is True and ts["activeAfterSame"] is True
|
||||
assert ts["fordTag"] == ["pipe-230.00"] # 포장층 표식 = 관 지점 알약 id
|
||||
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ def test_주인_카드_옛_꼴(out):
|
||||
def test_고름은_주인_구조물(out):
|
||||
for card in (out["neighbor"], out["owner"]):
|
||||
assert card["pick"] == ["pipe-90.00"]
|
||||
assert "is-active" in card["classes"][0].split()
|
||||
# 측점 길 고름(알약 · 목록 id)은 콘크리트를 안 켬 — 형상 누름 「관」 고름만(307)
|
||||
assert "is-active" not in card["classes"][0].split()
|
||||
|
||||
|
||||
def test_CSS_연결_꼴_강조가_먼저():
|
||||
|
||||
@@ -79,7 +79,8 @@ def test_배선_경사각도_상자():
|
||||
view = _read("B06_Section_UI_Cross_View.ts")
|
||||
assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view
|
||||
assert "revetOffset?.select(section.chainage_m, activeRevet, ownerM);" in view
|
||||
assert 'setShapeActive(key === "pipe");' in view
|
||||
# 「관」 = 관 그림 · 콘크리트 하나만(307)
|
||||
assert "splitPipePick(setRevetActive, setShapeActive, shapePicked)" in view
|
||||
assert len(view.splitlines()) <= 700
|
||||
shape = _read("B06_Section_UI_Cross_Shape_Pick.ts")
|
||||
assert 'thickenDown(shape.getAttribute("points") ?? "")' in shape
|
||||
|
||||
@@ -165,15 +165,25 @@ E.prototype.getBoundingClientRect = function () {
|
||||
return this.rect ?? { left: 0, top: 0, right: 0, bottom: 0, width: 400, height: 300 };
|
||||
};
|
||||
document.body = new E("body");
|
||||
const winKeys = [];
|
||||
const hashFns = [];
|
||||
const winFns = { keydown: [], hashchange: [], pointerdown: [], click: [], contextmenu: [] };
|
||||
const winKeys = winFns.keydown;
|
||||
const hashFns = winFns.hashchange;
|
||||
globalThis.window = { innerWidth: 1200, innerHeight: 800,
|
||||
addEventListener: (t, f) => (t === "keydown" ? winKeys : hashFns).push(f) };
|
||||
addEventListener: (t, f) => winFns[t].push(f) };
|
||||
const esc = () => {
|
||||
const ev = { key: "Escape", stopped: false, preventDefault() {}, stopPropagation() { ev.stopped = true; } };
|
||||
winKeys.forEach((f) => f(ev));
|
||||
return ev.stopped;
|
||||
};
|
||||
// 308 창 밖 누름 — pointerdown → click(또는 우클릭 메뉴) · 먹힘 = stopped
|
||||
const press = (target, type = "click") => {
|
||||
const ev = (t) => ({ type: t, target, stopped: false, preventDefault() {}, stopPropagation() { this.stopped = true; } });
|
||||
const down = ev("pointerdown");
|
||||
winFns.pointerdown.forEach((f) => f(down));
|
||||
const up = ev(type);
|
||||
winFns[type].forEach((f) => f(up));
|
||||
return [down.stopped, up.stopped];
|
||||
};
|
||||
const tables = [];
|
||||
const createDataTable = (o) => {
|
||||
const root = document.createElement("div");
|
||||
@@ -259,16 +269,16 @@ def test_이어진_창_겹침_Esc_차례():
|
||||
"\nconst b = boxes();"
|
||||
"\nconst dg = rows(2);"
|
||||
"\nconst e1 = esc(); const c = boxes();"
|
||||
"\nconst e2 = esc(); const e3 = esc(); const e4 = esc();"
|
||||
"\nconsole.log(JSON.stringify({ a, b, dg, top2: second.style.top, c, e: [e1, e2, e3, e4],"
|
||||
"\nconst e2 = esc();"
|
||||
"\nconsole.log(JSON.stringify({ a, b, dg, top2: second.style.top, c, e: [e1, e2],"
|
||||
" d: boxes(), gone }));"
|
||||
)
|
||||
assert out["a"] == ["bill"]
|
||||
assert out["b"] == ["bill", "labor", "dg"] # 겹쳐 위에(뒤 DOM = 위)
|
||||
assert out["dg"] == ["D1", "D1|1", "D1|합계"] # 호표 구간
|
||||
assert out["top2"] == "362px" # 그 줄(창 안 2번째 줄) 곁
|
||||
assert out["c"] == ["bill", "labor"] # Esc = 맨 위 하나
|
||||
assert out["e"] == [True, True, True, False] # 창 없으면 Esc 안 가로챔
|
||||
assert out["c"] == [] # 308 Esc = 모두
|
||||
assert out["e"] == [True, False] # 창 없으면 Esc 안 가로챔
|
||||
assert out["d"] == [] and out["gone"] == []
|
||||
|
||||
|
||||
@@ -295,6 +305,32 @@ def test_닫기_탭으로_새_누름_주소():
|
||||
assert out["e"] == [[], 2]
|
||||
|
||||
|
||||
def test_308_맨_위_창_밖_누름_모두_닫힘_누름_먹음():
|
||||
out = _popup(
|
||||
"\nconst outside = document.createElement('td'); document.body.append(outside);"
|
||||
"\nconst open3 = () => { openRowPopup({ ...base, link: { tab: 'bill', key: 'b1' }, anchor });"
|
||||
" tables[tables.length - 1].onCellClick(1, 'name');"
|
||||
" tables[tables.length - 1].onCellClick(0, 'name'); };"
|
||||
"\nconst pops = () => boxes().filter(Boolean);"
|
||||
"\nconst top = () => document.body.children[document.body.children.length - 1];"
|
||||
"\nconst under = () => document.body.children.filter((b) => b.dataset.b09Popup)[1];"
|
||||
"\nopen3(); const inTop = press(top().children[1]); const a = pops();" # 맨 위 창 안 = 그대로
|
||||
"\nconst right0 = press(top().children[0], 'contextmenu'); const a2 = pops();"
|
||||
"\nconst low = press(under().children[1]); const b = pops();" # 아래 창 누름
|
||||
"\nopen3(); const blank = press(outside); const c = pops();" # 쪽 빈 곳 · 톡
|
||||
"\nopen3(); const right = press(outside, 'contextmenu'); const d = pops();" # 우클릭
|
||||
"\nconst none = press(outside);" # 창 없음 = 안 가로챔
|
||||
"\nconsole.log(JSON.stringify({ inTop, a, right0, a2, low, b, blank, c, right, d, none, gone }));"
|
||||
)
|
||||
full = ["bill", "labor", "dg"]
|
||||
assert out["inTop"] == [False, False] and out["a"] == full # 맨 위 창 안 = 그대로
|
||||
assert out["right0"] == [False, False] and out["a2"] == full
|
||||
assert out["low"] == [True, True] and out["b"] == [] # 모두 닫힘 · 그 누름 먹음
|
||||
assert out["blank"] == [True, True] and out["c"] == []
|
||||
assert out["right"] == [True, True] and out["d"] == []
|
||||
assert out["none"] == [False, False] and out["gone"] == []
|
||||
|
||||
|
||||
def test_창_자리_화면_안():
|
||||
out = _popup(
|
||||
"\nconst v = { width: 1200, height: 800 };"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""PLAN 306 — B06 횡단 맹암거 안 돌 무늬 · 고르기 차례(요구 200-1).
|
||||
|
||||
- 306-1 돌 무늬 = 켜마다 반 칸 엇갈리고 이웃 · 위아래와 겹친 돌 · 기울기 번갈아 교차 · 그림 전용(누름 안 받음)
|
||||
- 306-2 고르기 차례 = 맹암거 안쪽 판정면 맨 아래(겹친 띠 · 관이 위) · 테두리 판정 = 면 판정 맨 위 ·
|
||||
보라 선 맨 위 그대로 · 다른 구조물 차례(작은 것 위) 그대로
|
||||
|
||||
실제 TS 를 `helper_306_underdrain_pick.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
B06 = os.path.join(ROOT, "B06_Section")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_306_underdrain_pick.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _rows(out: dict) -> list[list[dict]]:
|
||||
"""돌을 가로(따라) · 노면 아래 깊이(켜) 좌표로 — 깊이 같은 것끼리 한 켜."""
|
||||
(ax, ay), (bx, by) = out["body"][0], out["body"][1]
|
||||
rows: dict[float, list[dict]] = defaultdict(list)
|
||||
for s in out["stones"]:
|
||||
depth = s["cy"] - (ay + (s["cx"] - ax) * (by - ay) / (bx - ax))
|
||||
rows[round(depth, 1)].append({**s, "along": s["cx"]})
|
||||
return [sorted(rows[k], key=lambda s: s["along"]) for k in sorted(rows)]
|
||||
|
||||
|
||||
def test_돌_무늬_엇갈려_겹침_306_1(out):
|
||||
assert out["groupClass"] == "b06-chart__underdrain"
|
||||
assert out["stones"] and all(s["cls"] == "b06-chart__culvert-stone" for s in out["stones"])
|
||||
rows = _rows(out)
|
||||
assert len(rows) == 4 # 높이 1 m ÷ 0.25
|
||||
step = rows[0][1]["along"] - rows[0][0]["along"]
|
||||
gap = abs(rows[1][0]["along"] - rows[0][0]["along"]) % step
|
||||
assert abs(gap - step / 2) < 1e-6 # 이웃 켜는 반 칸 엇갈림
|
||||
for row in rows:
|
||||
# 돌 길이(따라) > 간격 · 돌 높이(가로질러) > 켜 간격 → 이웃 · 위아래와 겹침
|
||||
assert all(s["h"] > step * 1.3 for s in row)
|
||||
degs = [s["deg"] for s in row]
|
||||
assert all(a != b for a, b in zip(degs, degs[1:])) # 기울기 번갈아 → 교차
|
||||
row_gap = 20 / len(rows) # 암거 높이 1 m = 20 px
|
||||
assert all(s["w"] > row_gap for s in out["stones"])
|
||||
assert len({s["deg"] for s in out["stones"]}) == 2
|
||||
|
||||
|
||||
def test_돌_무늬는_누름_안_받음_306_1():
|
||||
with open(os.path.join(B06, "B06_Section_UI_Style_Cross_Areas.css"), encoding="utf-8") as f:
|
||||
css = f.read()
|
||||
assert re.search(r"\.b06-chart__underdrain \{\s*pointer-events: none;", css)
|
||||
assert re.search(r"\.b06-chart__shape-hit--edge \{[^}]*pointer-events: stroke;", css)
|
||||
|
||||
|
||||
def test_고르기_차례_306_2(out):
|
||||
# 안쪽 판정면 맨 아래 · 구조물 판정면 넓이 차례(작은 관 위) 그대로 · 테두리 = 면 판정 맨 위 · 보라 선 맨 위
|
||||
assert out["order"] == [
|
||||
"underdrain",
|
||||
"band",
|
||||
"g",
|
||||
"bigPipe",
|
||||
"smallPipe",
|
||||
"underdrain+edge",
|
||||
"road",
|
||||
]
|
||||
assert out["edgeCount"] == 1
|
||||
|
||||
|
||||
def test_누른_자리_받는_것_306_2(out):
|
||||
old, new = out["probesOld"], out["probesNew"]
|
||||
# 옛 차례 — 맹암거 안 성토 띠 · 맹암거보다 넓은 관이 막힘
|
||||
assert old["bandInUnderdrain"] == "underdrain" and old["pipeInUnderdrain"] == "underdrain"
|
||||
assert new == {
|
||||
"bandInUnderdrain": "band",
|
||||
"pipeInUnderdrain": "bigPipe",
|
||||
"smallPipe": "smallPipe",
|
||||
"underdrainOnly": "underdrain",
|
||||
"border": "underdrain+edge",
|
||||
"road": "road",
|
||||
}
|
||||
|
||||
|
||||
def test_테두리_누름은_맹암거_고름_306_2(out):
|
||||
assert out["picked"] == ["ud-1"]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""307 B06 고름 강조 — 측점 고름은 형상 안 켬 · 연동 관 = 콘크리트만 · 측점 없는 구조물 = 카드 · 스크롤 없음.
|
||||
|
||||
공통(「관」 setter 가름 · 벽 고름 콘크리트 표식)은 진짜 TS 를 Node 로 돌려 보고(helper_307),
|
||||
길마다(측점선 · 알약 · 좌측 목록 측점 · 영역 × 노면공 · 물넘이포장 · 연동 관 · 측점 없는 구조물)는
|
||||
그 길이 모이는 자리의 배선을 본다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).with_name("helper_307_pick_highlight.cjs")
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
return (ROOT.joinpath(*parts)).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
run = subprocess.run(
|
||||
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", cwd=ROOT
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
# ── 공통 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_공통_관_setter_콘크리트와_관_가름(out):
|
||||
split = out["split"]
|
||||
assert split["concretePipe"] == {"base": [None], "shape": [True]} # 콘크리트만
|
||||
assert split["pipePipe"] == {"base": ["pipe"], "shape": [False]} # 관만
|
||||
assert split["concreteWall"] == {"base": ["inlet"], "shape": [False]} # 다른 벽 그대로
|
||||
assert split["none"] == {"base": [None], "shape": [False]}
|
||||
|
||||
|
||||
def test_공통_벽고름_콘크리트_표식(out):
|
||||
cases = out["revetCases"]
|
||||
assert cases["pipeOnPipeCard"] == {"shape": False, "selected": "pipe"}
|
||||
assert cases["concreteOnPipeCard"] == {"shape": True, "selected": "pipe"}
|
||||
assert cases["concreteOnOwnCard"] == {"shape": True, "selected": "pipe"}
|
||||
assert cases["handoffOnOwnCard"] == {"shape": True, "selected": "pipe"}
|
||||
assert cases["wall"] == {"shape": False, "selected": "inlet"}
|
||||
assert cases["released"] == {"shape": False, "selected": None}
|
||||
assert cases["changes"] == 6 # 고를 때마다 연동 카드 다시 칠함(그대로)
|
||||
|
||||
|
||||
# ── 물넘이포장 × 측점 길 넷 ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_물넘이포장_측점길은_콘크리트_안켬(out):
|
||||
# 측점선 · 알약 · 좌측 목록 측점 · 영역 = 끝에 `selectMark("pipe-…")` → highlightShapes
|
||||
assert out["byStationPath"] == {"concreteOff": False, "concreteLitKept": True}
|
||||
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
assert "highlightShapes(card, pickedMarkId);" in view
|
||||
shape = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts")
|
||||
assert 'if (id.startsWith("pipe-")) continue;' in shape
|
||||
# 네 길이 모이는 자리 — 카드 고름 리스너 · 목록 관 줄 · 폼 관 열기 · 측점 없는 관
|
||||
cross = _read("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "sectionView.selectMark(mark, false);" in cross
|
||||
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
|
||||
assert "deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`);" in panel # 카드 → 폼
|
||||
assert "deps.markStructure?.(chainageM === null ? null : `pipe-${" in panel # 목록 관 줄
|
||||
# 측점 길은 「관」 벽 고름을 안 엶 — 카드만 고르면 벽 고름을 비움(그대로)
|
||||
assert "stationControls.revetOffset.select(target.chainage_m, null);" in cross
|
||||
|
||||
|
||||
def test_물넘이포장_형상누름은_콘크리트(out):
|
||||
view = _read("B06_Section", "B06_Section_UI_Cross_View.ts")
|
||||
assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view
|
||||
assert "splitPipePick(setRevetActive, setShapeActive, shapePicked)" in view
|
||||
assert "setRevetActive(highlightRevet);" in view
|
||||
assert out["revetCases"]["concreteOnOwnCard"]["shape"] is True
|
||||
|
||||
|
||||
# ── 노면공 × 측점 길 넷 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_노면공_포장층은_포장층_누름만():
|
||||
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
assert "const pave = span && isPavement(spanMarkId) && paveShapeId === spanMarkId;" in view
|
||||
# 알약 · 목록 · 측점 길 = 누름 턴 밖 selectMark → 포장층 표식 풂
|
||||
assert "if (!paveShapeTurn) paveShapeId = null;" in view
|
||||
# 포장층 누름 = 턴 안에서 목록 고름 길을 타고 · 끝나면 그 노면공 포장층만 켬
|
||||
grid = re.search(
|
||||
r"const pave = pavementAt\(event\.target, spanMarks\);(.*?)\n \},", view, re.S
|
||||
)
|
||||
assert grid, "포장층 누름 처리 없음"
|
||||
body = grid.group(1)
|
||||
assert body.index("paveShapeTurn = true;") < body.index("pavementSelectListener?.(")
|
||||
assert body.index("paveShapeTurn = false;") < body.index("paveShapeId = pave.structure_id;")
|
||||
assert body.index("paveShapeId = pave.structure_id;") < body.index("syncSpanHighlights();")
|
||||
# 범위 테두리(--span)는 그대로 — 다른 구간형 구조물과 같은 길
|
||||
assert 'card.classList.toggle("b06-cross-card--span", span);' in view
|
||||
assert "spanMarkId = span || isPavement(structureId) ? structureId : null;" in view
|
||||
|
||||
|
||||
# ── 연동 관 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_연동관_콘크리트누름은_관형상_안켬(out):
|
||||
assert out["revetCases"]["concreteOnPipeCard"] == {"shape": True, "selected": "pipe"}
|
||||
assert out["split"]["concretePipe"]["base"] == [None] # 관 그림 setter 엔 「관」 안 감
|
||||
select = _read("B06_Section", "B06_Section_UI_Page_Structures_Selection.ts")
|
||||
assert "origRevet(chainageM, key, ownerM);" in select
|
||||
view = _read("B06_Section", "B06_Section_UI_Cross_View.ts")
|
||||
# 콘크리트 ↔ 관 그림 바꿔 누름 = 풀지 않고 옮김 · 같은 것 다시 누름 = 풂
|
||||
assert (
|
||||
"activeRevet = activeRevet === key && (ownerM !== undefined) === shapePicked() ? null : key;"
|
||||
in view
|
||||
)
|
||||
# 좌측 하위 컨테이너 켬(경사각도 상자)은 그대로 — 「관」 벽 고름 길
|
||||
dock = _read("B06_Section", "B06_Section_UI_Adjust_Dock.ts")
|
||||
assert 'side === "pipe" && (!own || own.closest("[hidden]")) ? visibleBox(SLOPE_BOX)' in dock
|
||||
|
||||
|
||||
# ── 측점 없는 구조물 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_측점없는_구조물_카드_스크롤_없음():
|
||||
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
body = re.search(r"revealStation\(\) \{(.*?)\n \},", view, re.S)
|
||||
assert body, "revealStation 없음"
|
||||
assert "revealCard" not in body.group(1)
|
||||
assert "markCard(previous);" in body.group(1) # 옛 카드는 풂
|
||||
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
|
||||
# B06 알약 · 좌측 목록 = 폼 onSelect · 측점 없는 관 = selectPipeAt — 둘 다 revealOnly 길
|
||||
assert (
|
||||
"const revealOnly = !coverable(structure) || !plantsStation(structure, markTypes);" in panel
|
||||
)
|
||||
assert "deps.focusChainage(pipe.chainage_m, true);" in panel
|
||||
# B05 알약 · 3D · 지도 넘김 = focusPicked — 측점 없는 구조물은 카드 고름을 안 부름
|
||||
focus = re.search(r"focusPicked: \(structureId, focus\) => \{(.*?)\n \},", panel, re.S)
|
||||
assert focus, "focusPicked 없음"
|
||||
assert "if (!known || plantsStation(known, markTypes)) {" in focus.group(1)
|
||||
assert "if (known) section.selectById(structureId);" in focus.group(1)
|
||||
cross = _read("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "structuresPanel.focusPicked(handoff?.structureId ?? null, () =>" in cross
|
||||
profile = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts")
|
||||
assert "edit.selectStructure(hit.structure_id);" in profile
|
||||
|
||||
|
||||
# ── 지킬 것 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_맹암거_형상강조_그대로(out):
|
||||
assert out["drainLit"] is True
|
||||
assert out["afterRelease"] == {"drain": False, "concreteLit": True}
|
||||
|
||||
|
||||
def test_줄_한도():
|
||||
assert len(_read("A00_Common", "design", "design_view_cross.ts").splitlines()) <= 685
|
||||
for name in (
|
||||
"B06_Section_UI_Cross_View.ts",
|
||||
"B06_Section_UI_Section_View.ts",
|
||||
"B06_Section_UI_Page_Station_Controls.ts",
|
||||
"B06_Section_UI_Page_Structures_Panel.ts",
|
||||
):
|
||||
assert len(_read("B06_Section", name).splitlines()) <= 700, name
|
||||
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""311 좌측 패널 기본 접힘 · B07 첫 쪽 = 표지 (요구 204).
|
||||
|
||||
못박는 것
|
||||
- 기본 접힘 = B09 「데이터 점검」 · 「공기 옵션」 / B07 「평면도」 · 「종단도」 · 「횡단도」 /
|
||||
B06 「횡단 보기 설정」 / B05 「계획노선」 · 다른 컨테이너 기본값 그대로.
|
||||
- 쪽 안에서 편 것은 다시 그려도 그대로 — B05 · B06 = 한 벌 패널이 칸을 한 번만 세움 · B07 = 다시 세울 때
|
||||
옛 목록 접힘을 이어 받음 · B09 = 좌측 패널을 쪽 그릴 때 한 번만 세움.
|
||||
- B07 처음 들어가면 표지 — 고른 장 기억(모듈 값 `reopenDrawingId` · `reopenCrossSheet`)이 있으면 그 장.
|
||||
|
||||
Node 헬퍼 `helper_311_panel_folded.cjs` 가 가짜 DOM 위에서 B05 · B06 · B07 패널을 돌림.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = Path(HERE).parents[1]
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_311_panel_folded.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_B05_B06_첫_그림_접힘(out):
|
||||
assert out["profileFirst"] == {
|
||||
"페이지 설정": True,
|
||||
"계획노선": True,
|
||||
"선택 포인트 상세 설정": False,
|
||||
}
|
||||
assert out["crossFirst"]["B06_View_Title"] is True
|
||||
assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로
|
||||
|
||||
|
||||
def test_B05_B06_편_뒤_다시_그림_유지(out):
|
||||
assert out["crossFirst"]["계획노선"] is False # 편 것 → 보기 바꿔도 그대로
|
||||
assert out["afterRedraw"]["계획노선"] is False
|
||||
assert out["afterRedraw"]["B06_View_Title"] is False
|
||||
assert out["sameRouteNode"] is True
|
||||
|
||||
|
||||
def test_B07_첫_그림_접힘(out):
|
||||
assert out["b07EntryFolded"] == [] # 들어올 때 = 라우터가 비운 그릇 → 기본값
|
||||
assert out["b07First"] == {
|
||||
"plan": True,
|
||||
"longitudinal": True,
|
||||
"cross": True,
|
||||
"mass_haul": False,
|
||||
}
|
||||
|
||||
|
||||
def test_B07_다시_세워도_접힘_이어_받음(out):
|
||||
assert out["b07Redraw"] == {
|
||||
"plan": True,
|
||||
"longitudinal": True,
|
||||
"cross": False,
|
||||
"mass_haul": True,
|
||||
}
|
||||
|
||||
|
||||
def test_B07_다시_세우기는_옛_목록을_기다리기_전에_읽음():
|
||||
page = _read("B07_DesignDetail/B07_DesignDetail_UI_Page.ts")
|
||||
body = page[page.index("export async function renderB07DesignDetail(") :]
|
||||
assert body.index("const folded = foldedGroups(root);") < body.index("await ")
|
||||
assert "crossLayoutSlot,\n folded,\n );" in body.replace("\r\n", "\n")
|
||||
|
||||
|
||||
def test_B07_첫_장은_표지_기억_있으면_그_장():
|
||||
page = _read("B07_DesignDetail/B07_DesignDetail_UI_Page.ts")
|
||||
assert 'const cover = drawings.findIndex((item) => item.kind === "cover");' in page
|
||||
assert "const first = reopen >= 0 ? reopen : Math.max(cover, 0);" in page
|
||||
|
||||
|
||||
def test_B09_기본_접힘_좌측은_한_번만_세움():
|
||||
shell = _read("B09_Estimation/B09_Estimation_UI_Shell.ts")
|
||||
assert 'createPanelCard("데이터 점검", true)' in shell
|
||||
assert 'createPanelCard("공기 옵션", true)' in _read(
|
||||
"B09_Estimation/draft/B09_Draft_Schedule_Panel.ts"
|
||||
)
|
||||
# 다시 그림(show) 은 가운데 판만 바꿈 — 좌측 카드는 쪽 그릴 때 한 번
|
||||
show = shell[shell.index("const show = (tab: TabId") : shell.index("const run = async (")]
|
||||
assert "createPanelCard" not in show and "left." not in show
|
||||
print_page = _read("B09_Estimation/draft/B09_Draft_Print_Page.ts")
|
||||
assert 'createPanelCard("영역")' in print_page and 'createPanelCard("쪽 목록")' in print_page
|
||||
@@ -383,7 +383,7 @@ function isNode(lv: NonNullable<DataTableOptions["level"]>, i: number, n: number
|
||||
const OVER = 40;
|
||||
|
||||
/* ── 창 아래 끝까지(PLAN 199) — 칸 최대 높이 = 창 높이 − 칸 위쪽 자리 − 아래 자리 ────────────
|
||||
* 아래 자리 = 칸 뒤 형제 요소 높이 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백.
|
||||
* 아래 자리 = 칸 뒤 형제 요소 높이 · 바깥 여백 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백.
|
||||
* 줄이 적으면 내용만큼 · 창 크기 · 위아래 요소가 바뀌면 다시 셈 · 칸이 문서에서 떨어지면 스스로 멈춤.
|
||||
* ------------------------------------------------------------------------------------------ */
|
||||
|
||||
@@ -407,7 +407,8 @@ export function spaceBelow(el: HTMLElement): number {
|
||||
const gap = px(around.rowGap);
|
||||
for (let s = node.nextElementSibling; s; s = s.nextElementSibling) {
|
||||
const h = s.getBoundingClientRect().height;
|
||||
if (h) sum += h + gap;
|
||||
const m = getComputedStyle(s); // 203 형제 위아래 바깥 여백도(B09 안내 글 margin-top)
|
||||
if (h) sum += h + gap + px(m.marginTop) + px(m.marginBottom);
|
||||
}
|
||||
sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user