Merge remote-tracking branch 'origin/CODEX' into CODEX
This commit is contained in:
@@ -101,6 +101,8 @@ export const STATE_REGISTRY = {
|
||||
},
|
||||
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
|
||||
"cross-columns": { bucket: "pref", scope: "global" },
|
||||
/** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */
|
||||
"mass-haul-badge-pos": { bucket: "pref", scope: "global" },
|
||||
"section-panel-height": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import { attachMassHaulBadgeDrag, createMassHaulBadge } from "@util/common_util_mass_haul_badge";
|
||||
import { readStateRaw, writeStateRaw } from "../b_page_state";
|
||||
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
|
||||
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
|
||||
import {
|
||||
@@ -31,6 +32,11 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
|
||||
// 그래프 위 도구줄 · 측점 ▲ 줄(바닥 47px) 아래로 내려 겹치지 않게 한다(289).
|
||||
massBadge.root.style.setProperty("--mass-haul-badge-top", "52px");
|
||||
bodyWrap.append(massBadge.root);
|
||||
// 그래프 안 끌어 옮김(309-2) — 자리는 화면 설정 한 칸(B05 · B06 같은 값)
|
||||
attachMassHaulBadgeDrag(massBadge.root, body, {
|
||||
read: () => readStateRaw("mass-haul-badge-pos"),
|
||||
write: (value) => writeStateRaw("mass-haul-badge-pos", value),
|
||||
});
|
||||
let massHaulSummary: MassHaulSummaryValues | null = null;
|
||||
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
|
||||
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -382,29 +382,35 @@
|
||||
/* Y축 띠(고정 축) 오른쪽에 놓는다 — 8px 로 두면 축 라벨 위에 겹쳐 값이 잘린다
|
||||
(2026-09-06 실측). 축 폭이 다른 화면은 이 변수를 덮어쓴다. */
|
||||
left: var(--mass-haul-badge-left, 86px);
|
||||
z-index: 4;
|
||||
/* 바닥 서브패널(유토곡선 · 테이블 5) · 손잡이(6) 위 — 어디로 끌어도 보이고 다시 잡힘(309-2) */
|
||||
z-index: 7;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4, 4px);
|
||||
/* 큰 알약(289) — 글자 · 여백 한 단 위 */
|
||||
padding: var(--spacing-4, 4px) var(--spacing-12, 12px);
|
||||
/* 붉은 계통(289-2) — 테두리 · 글자 · 옅은 바탕 = 위험색 섞음(밝음 · 어두움 같이) */
|
||||
/* 붉은 계통(289-2) — 테두리 · 글자 · 옅은 바탕 = 위험색 섞음(밝음 · 어두움 같이)
|
||||
바탕은 불투명(309-1) — 그래프 선 · 채움이 비치지 않게 · 글자 대비 = 두 테마 모두 4.5 : 1 넘음 */
|
||||
border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent);
|
||||
border-radius: var(--radius-pills, 999px);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-danger) 6%,
|
||||
color-mix(in srgb, var(--color-surface-raised) 88%, transparent)
|
||||
);
|
||||
background: color-mix(in srgb, var(--color-danger) 8%, var(--color-surface-raised));
|
||||
color: color-mix(in srgb, var(--color-danger) 70%, var(--color-text-body));
|
||||
font-size: var(--pill-font-size-lg);
|
||||
line-height: var(--leading-body-sm);
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
/* 그래프 안 끌어 옮기기(309-2) — 손가락 끌기가 화면 밀기 · 글 고름으로 새지 않게 */
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mass-haul-badge.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mass-haul-badge em {
|
||||
color: color-mix(in srgb, var(--color-danger) 55%, var(--color-text-secondary));
|
||||
color: color-mix(in srgb, var(--color-danger) 55%, var(--color-text-body));
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,3 +84,100 @@ export function createMassHaulBadge(): MassHaulBadge {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 알약 자리 저장 창구(화면 설정 한 칸) — 값 = `{"x":0~1,"y":0~1}` 글. */
|
||||
export interface MassHaulBadgePlace {
|
||||
read: () => string | null;
|
||||
write: (value: string) => void;
|
||||
}
|
||||
|
||||
/** 끈 것으로 보는 움직임(px) — 이 안이면 그냥 누름(지금 동작 그대로). */
|
||||
const DRAG_SLOP_PX = 4;
|
||||
|
||||
/**
|
||||
* 알약 끌어 옮기기(309-2) — 마우스 · 손가락 같은 pointer 길 · `area`(그래프 스크롤러)의 보이는 상자 안에서만.
|
||||
* 영역 = 알약 처음 자리(Y축 띠 오른쪽 · 측점 ▲ 줄 아래)가 왼쪽 위 끝 · 스크롤러 보이는 오른쪽 · 아래 끝까지.
|
||||
* 자리 = 영역 안 비율 — 폭 · 높이가 바뀌거나 다시 그려도 같은 비율. 알약은 그래프 형제라 끌기가 그래프 고름에 안 닿음.
|
||||
*/
|
||||
export function attachMassHaulBadgeDrag(
|
||||
root: HTMLElement,
|
||||
area: HTMLElement,
|
||||
place: MassHaulBadgePlace,
|
||||
): void {
|
||||
let origin: { x: number; y: number } | null = null;
|
||||
const bounds = () => {
|
||||
if (!origin) {
|
||||
const [left, top] = [root.style.left, root.style.top];
|
||||
root.style.left = root.style.top = "";
|
||||
origin = { x: root.offsetLeft, y: root.offsetTop };
|
||||
[root.style.left, root.style.top] = [left, top];
|
||||
}
|
||||
const w = area.offsetLeft + area.clientWidth - root.offsetWidth - origin.x;
|
||||
const h = area.offsetTop + area.clientHeight - root.offsetHeight - origin.y;
|
||||
return { ...origin, w: Math.max(0, w), h: Math.max(0, h) };
|
||||
};
|
||||
const clamp = (v: number): number => Math.min(1, Math.max(0, v));
|
||||
const put = (x: number, y: number): void => {
|
||||
const b = bounds();
|
||||
root.style.left = `${Math.round(b.x + clamp(x) * b.w)}px`;
|
||||
root.style.top = `${Math.round(b.y + clamp(y) * b.h)}px`;
|
||||
};
|
||||
const restore = (): void => {
|
||||
if (root.hidden || !root.isConnected) return;
|
||||
try {
|
||||
const v = JSON.parse(place.read() ?? "null") as { x?: unknown; y?: unknown } | null;
|
||||
if (typeof v?.x === "number" && typeof v.y === "number") put(v.x, v.y);
|
||||
} catch {
|
||||
/* 못 읽는 값 = 처음 자리 */
|
||||
}
|
||||
};
|
||||
new ResizeObserver(restore).observe(area);
|
||||
new MutationObserver(restore).observe(root, { attributeFilter: ["hidden"] });
|
||||
|
||||
let drag: { id: number; px: number; py: number; left: number; top: number } | null = null;
|
||||
let moved: { x: number; y: number } | null = null;
|
||||
root.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
drag = {
|
||||
id: e.pointerId,
|
||||
px: e.clientX,
|
||||
py: e.clientY,
|
||||
left: root.offsetLeft,
|
||||
top: root.offsetTop,
|
||||
};
|
||||
moved = null;
|
||||
root.setPointerCapture(e.pointerId);
|
||||
});
|
||||
root.addEventListener("pointermove", (e) => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
const dx = e.clientX - drag.px;
|
||||
const dy = e.clientY - drag.py;
|
||||
if (!moved && Math.hypot(dx, dy) < DRAG_SLOP_PX) return;
|
||||
const b = bounds();
|
||||
moved = {
|
||||
x: b.w ? clamp((drag.left + dx - b.x) / b.w) : 0,
|
||||
y: b.h ? clamp((drag.top + dy - b.y) / b.h) : 0,
|
||||
};
|
||||
root.classList.add("is-dragging");
|
||||
put(moved.x, moved.y);
|
||||
});
|
||||
const end = (e: PointerEvent): void => {
|
||||
if (!drag || e.pointerId !== drag.id) return;
|
||||
drag = null;
|
||||
root.classList.remove("is-dragging");
|
||||
if (moved) place.write(JSON.stringify({ x: +moved.x.toFixed(4), y: +moved.y.toFixed(4) }));
|
||||
};
|
||||
root.addEventListener("pointerup", end);
|
||||
root.addEventListener("pointercancel", end);
|
||||
// 끈 뒤 따라오는 click 은 삼킴 — 안 끈 누름은 그대로 지나감
|
||||
root.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!moved) return;
|
||||
moved = null;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -70,9 +70,13 @@ def test_badge_red_tone_uses_theme_danger():
|
||||
badge = _block(css, ".mass-haul-badge")
|
||||
assert "border: 1px solid color-mix(in srgb, var(--color-danger) 55%, transparent)" in badge
|
||||
assert "color: color-mix(in srgb, var(--color-danger) 70%, var(--color-text-body))" in badge
|
||||
assert "var(--color-danger) 6%" in badge
|
||||
# 바탕 불투명(309-1) — 그래프 선 · 채움이 비치지 않게
|
||||
assert (
|
||||
"background: color-mix(in srgb, var(--color-danger) 8%, var(--color-surface-raised))"
|
||||
in badge
|
||||
)
|
||||
em = _block(css, ".mass-haul-badge em")
|
||||
assert "color-mix(in srgb, var(--color-danger) 55%, var(--color-text-secondary))" in em
|
||||
assert "color-mix(in srgb, var(--color-danger) 55%, var(--color-text-body))" in em
|
||||
over = _block(css, ".mass-haul-badge.is-over")
|
||||
assert "border-color: var(--color-danger)" in over
|
||||
assert "var(--color-danger) 12%" in over
|
||||
@@ -83,9 +87,9 @@ def test_badge_red_tone_uses_theme_danger():
|
||||
|
||||
def test_badge_red_tone_contrast_both_themes():
|
||||
for name, t in THEMES.items():
|
||||
bg = _mix(t["danger"], 6, t["surface"])
|
||||
bg = _mix(t["danger"], 8, t["surface"])
|
||||
text = _mix(t["danger"], 70, t["body"])
|
||||
caption = _mix(t["danger"], 55, t["secondary"])
|
||||
caption = _mix(t["danger"], 55, t["body"])
|
||||
over_bg = _mix(t["danger"], 12, t["surface"])
|
||||
over_value = _mix(t["danger"], 85, t["body"]) # 음수 값 = 위험색 더 많이
|
||||
assert _contrast(text, bg) >= 4.5, name
|
||||
|
||||
@@ -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,185 @@
|
||||
"""B05 · B06 종단 누가토량 알약 — 테마 색 · 그래프 안 끌기(PLAN 309 · 요구 202) — 실제 브라우저(5174).
|
||||
|
||||
① 색: 밝음 · 어두움 × 보통 · 음수 — 바탕 불투명 · 글자(제목 · 값) 대비 4.5 넘음 · 붉은 계통(빨강이 가장 큼)
|
||||
② 끌기(마우스): 움직임 · 끈 뒤 click 은 삼킴 · 안 끈 누름은 지나감 · 영역 끝 막힘(오른쪽 아래 · 왼쪽 위 = 처음 자리)
|
||||
③ 자리 기억: 화면 설정 한 칸(계정 올리기 PUT 에 실림 — 시험은 가로채 서버에 안 씀) · 다시 열어도 · 창 크기 바꿔도 같은 비율
|
||||
④ B06 도 같은 자리 · 손가락 끌기(터치 pointer)도 같은 길
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import test_device_audit as audit # noqa: E402
|
||||
|
||||
KEY = "aislo:pref:mass-haul-badge-pos"
|
||||
# 그래프 고름 · 종단 편집 흔적 — 고른 것 수 · 되돌리기 단추 켜짐
|
||||
PICKED = (
|
||||
"[document.querySelectorAll('.b05-route-profile__body :is(.is-selected, .is-picked,"
|
||||
" .is-active)').length, location.hash]"
|
||||
)
|
||||
BOX = (
|
||||
"(() => { const b = document.querySelector('.mass-haul-badge'), a = b.parentElement"
|
||||
".querySelector('.b05-route-profile__body'); const r = b.getBoundingClientRect(),"
|
||||
" q = a.getBoundingClientRect(); return {l: r.left, t: r.top, r: r.right, b: r.bottom,"
|
||||
" w: r.width, h: r.height, ar: q.left + a.clientWidth, ab: q.top + a.clientHeight,"
|
||||
" al: q.left, at: q.top}; })()"
|
||||
)
|
||||
CONTRAST = r"""
|
||||
(theme) => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
const parse = (s) => { const m = s.match(/[\d.]+/g).map(Number);
|
||||
const k = s.startsWith('color(') ? 255 : 1;
|
||||
return {r: m[0] * k, g: m[1] * k, b: m[2] * k, a: m.length > 3 ? m[3] : 1}; };
|
||||
const lum = (c) => { const ch = [c.r, c.g, c.b].map((v) => { v /= 255;
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; });
|
||||
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2]; };
|
||||
const ratio = (x, y) => { const [h, l] = [lum(x), lum(y)].sort((p, q) => q - p);
|
||||
return (h + 0.05) / (l + 0.05); };
|
||||
const badge = document.querySelector('.mass-haul-badge');
|
||||
const was = badge.classList.contains('is-over');
|
||||
const out = [];
|
||||
for (const over of [false, true]) {
|
||||
badge.classList.toggle('is-over', over);
|
||||
const bg = parse(getComputedStyle(badge).backgroundColor);
|
||||
for (const t of ['em', 'strong']) {
|
||||
const fg = parse(getComputedStyle(badge.querySelector(t)).color);
|
||||
out.push({over, t, alpha: bg.a, ratio: ratio(fg, bg), red: fg.r > fg.g && fg.r > fg.b});
|
||||
}
|
||||
}
|
||||
badge.classList.toggle('is-over', was);
|
||||
return out;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _ctx(browser, cred, touch=False, saved=None):
|
||||
ctx = browser.new_context(
|
||||
viewport={"width": 1440, "height": 900}, has_touch=touch, is_mobile=False
|
||||
)
|
||||
audit._login(ctx, cred)
|
||||
pid = audit._project_id(ctx)
|
||||
ctx.add_init_script(f"localStorage.setItem('frd_current_project_id', {json.dumps(pid)})")
|
||||
pushed = []
|
||||
|
||||
# 계정 취향 — 받기는 빈 값(처음 자리부터) · 올리기는 가로채 서버에 안 씀
|
||||
def prefs(route):
|
||||
if route.request.method == "PUT":
|
||||
pushed.append(json.loads(route.request.post_data or "{}").get("prefs", {}))
|
||||
body = {"prefs": saved or {}} if route.request.method == "GET" else {"ok": True}
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps(body))
|
||||
|
||||
ctx.route("**/api/dashboard/me/ui-prefs", prefs)
|
||||
return ctx, pushed
|
||||
|
||||
|
||||
def _drag(page, dx, dy):
|
||||
b = page.evaluate(BOX)
|
||||
x, y = b["l"] + b["w"] / 2, b["t"] + b["h"] / 2
|
||||
page.mouse.move(x, y)
|
||||
page.mouse.down()
|
||||
page.mouse.move(x + dx / 2, y + dy / 2, steps=4)
|
||||
page.mouse.move(x + dx, y + dy, steps=4)
|
||||
page.mouse.up()
|
||||
|
||||
|
||||
def _saved(page):
|
||||
return json.loads(page.evaluate(f"localStorage.getItem({json.dumps(KEY)})") or "null")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not audit._server_up(), reason="sub1 화면 서버(5174) 꺼짐")
|
||||
def test_mass_haul_badge_color_and_drag():
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
cred = json.loads((audit.ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(channel="msedge", headless=True)
|
||||
ctx, pushed = _ctx(browser, cred)
|
||||
page = ctx.new_page()
|
||||
audit._open(page, "b05-profile")
|
||||
page.wait_for_selector(".mass-haul-badge:not([hidden])", timeout=30000)
|
||||
|
||||
# ①
|
||||
for theme in ("light", "dark"):
|
||||
for row in page.evaluate(CONTRAST, theme):
|
||||
assert row["alpha"] == 1 and row["ratio"] >= 4.5 and row["red"], (theme, row)
|
||||
page.evaluate("document.documentElement.removeAttribute('data-theme')")
|
||||
|
||||
# ② 안 끈 누름 = 지나감 · 끈 뒤 click = 삼킴
|
||||
page.evaluate(
|
||||
"window.__clicks = 0; document.addEventListener('click', (e) =>"
|
||||
" e.target.closest?.('.mass-haul-badge') && window.__clicks++)"
|
||||
)
|
||||
start = page.evaluate(BOX)
|
||||
picked = page.evaluate(PICKED)
|
||||
page.click(".mass-haul-badge")
|
||||
assert page.evaluate("window.__clicks") == 1
|
||||
assert page.evaluate(BOX)["l"] == start["l"]
|
||||
assert _saved(page) is None
|
||||
_drag(page, 200, 60)
|
||||
moved = page.evaluate(BOX)
|
||||
assert abs(moved["l"] - start["l"] - 200) <= 2 and abs(moved["t"] - start["t"] - 60) <= 2
|
||||
assert page.evaluate("window.__clicks") == 1
|
||||
_drag(page, 5000, 5000) # 오른쪽 아래 끝 막힘
|
||||
end = page.evaluate(BOX)
|
||||
assert (
|
||||
end["ar"] - 1 <= end["r"] <= end["ar"] + 1
|
||||
and end["ab"] - 1 <= end["b"] <= end["ab"] + 1
|
||||
)
|
||||
assert _saved(page) == {"x": 1, "y": 1}
|
||||
_drag(page, -5000, -5000) # 왼쪽 위 끝 = 처음 자리
|
||||
assert page.evaluate(BOX)["l"] == start["l"] and page.evaluate(BOX)["t"] == start["t"]
|
||||
assert _saved(page) == {"x": 0, "y": 0}
|
||||
assert page.evaluate(PICKED) == picked # 끌기가 그래프 고름 · 편집을 안 일으킴
|
||||
|
||||
# ③ 비율 기억 · 계정 올리기에 실림 · 다시 열기 · 창 크기
|
||||
_drag(page, 300, 100)
|
||||
ratio = _saved(page)
|
||||
assert 0 < ratio["x"] < 1 and 0 < ratio["y"] < 1
|
||||
page.wait_for_timeout(2000) # 올리기 모음 1.5초
|
||||
assert any(KEY in push for push in pushed)
|
||||
audit._open(page, "b05-profile")
|
||||
page.wait_for_selector(".mass-haul-badge:not([hidden])", timeout=30000)
|
||||
b = page.evaluate(BOX)
|
||||
x0, y0 = start["l"] - start["al"], start["t"] - start["at"]
|
||||
span_x = b["ar"] - b["al"] - x0 - b["w"]
|
||||
span_y = b["ab"] - b["at"] - y0 - b["h"]
|
||||
assert abs((b["l"] - b["al"] - x0) - ratio["x"] * span_x) <= 2, (b, ratio)
|
||||
assert abs((b["t"] - b["at"] - y0) - ratio["y"] * span_y) <= 2, (b, ratio)
|
||||
page.set_viewport_size({"width": 1200, "height": 800})
|
||||
page.wait_for_timeout(800)
|
||||
b = page.evaluate(BOX)
|
||||
span_x = b["ar"] - b["al"] - x0 - b["w"]
|
||||
assert abs((b["l"] - b["al"] - x0) - ratio["x"] * span_x) <= 2, (b, ratio)
|
||||
assert b["r"] <= b["ar"] + 1 and b["b"] <= b["ab"] + 1
|
||||
ctx.close()
|
||||
|
||||
# ④ B06 같은 자리(계정 값으로 받음) · 손가락 끌기
|
||||
tctx, _ = _ctx(browser, cred, touch=True, saved={KEY: json.dumps(ratio)})
|
||||
tp = tctx.new_page()
|
||||
audit._open(tp, "b06-section")
|
||||
tp.wait_for_selector(".mass-haul-badge:not([hidden])", timeout=30000)
|
||||
b = tp.evaluate(BOX)
|
||||
span_x = b["ar"] - b["al"] - x0 - b["w"]
|
||||
assert abs((b["l"] - b["al"] - x0) - ratio["x"] * span_x) <= 2, (b, ratio)
|
||||
cdp = tctx.new_cdp_session(tp)
|
||||
x, y = b["l"] + b["w"] / 2, b["t"] + b["h"] / 2
|
||||
cdp.send(
|
||||
"Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": x, "y": y}]}
|
||||
)
|
||||
for i in range(1, 6):
|
||||
cdp.send(
|
||||
"Input.dispatchTouchEvent",
|
||||
{"type": "touchMove", "touchPoints": [{"x": x - 20 * i, "y": y - 8 * i}]},
|
||||
)
|
||||
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
|
||||
after = tp.evaluate(BOX)
|
||||
assert abs(after["l"] - b["l"] + 100) <= 2 and abs(after["t"] - b["t"] + 40) <= 2, (
|
||||
b,
|
||||
after,
|
||||
)
|
||||
assert _saved(tp) != ratio
|
||||
browser.close()
|
||||
@@ -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