Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_2
This commit is contained in:
@@ -78,10 +78,12 @@ export function coversChainage(structure: SelectableStructure, chainageM: number
|
||||
* ① 구조물 id 가 있고 목록에 있으면 그 구조물 ② 측점 근처 관(계곡 통과 시설이 먼저 —
|
||||
* 같은 측점에 구조물이 겹쳐도 횡단도에 그려지는 것은 관이다) ③ 측점을 덮는 구조물.
|
||||
*/
|
||||
export function resolveSelection(
|
||||
export function resolveSelection<T extends SelectableStructure>(
|
||||
selection: DesignSelection,
|
||||
pipes: ReadonlyArray<{ chainage_m: number }>,
|
||||
structures: ReadonlyArray<SelectableStructure>,
|
||||
structures: ReadonlyArray<T>,
|
||||
/** ③ 덮는 구조물로 열어도 되나 — B06 은 노면공을 뺌(152-5 · ① id 길은 그대로). */
|
||||
coverable: (entry: T) => boolean = () => true,
|
||||
): ResolvedSelection {
|
||||
if (selection.structureId) {
|
||||
const byId = structures.find((entry) => entry.structure_id === selection.structureId);
|
||||
@@ -91,7 +93,7 @@ export function resolveSelection(
|
||||
if (chainageM === null) return { pipeChainageM: null, structureId: null };
|
||||
const pipe = nearestWithin(pipes, chainageM, (entry) => entry.chainage_m);
|
||||
if (pipe) return { pipeChainageM: pipe.chainage_m, structureId: null };
|
||||
const hit = structures.find((entry) => coversChainage(entry, chainageM));
|
||||
const hit = structures.find((entry) => coverable(entry) && coversChainage(entry, chainageM));
|
||||
return { pipeChainageM: null, structureId: hit?.structure_id ?? null };
|
||||
}
|
||||
|
||||
|
||||
@@ -53,11 +53,11 @@ import {
|
||||
rememberRockBoundaryDefault,
|
||||
type StandardPanelController,
|
||||
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
import { createB06StructuresPanel } from "../../B06_Section/B06_Section_UI_Page_Structures_Panel";
|
||||
import {
|
||||
createB06StructuresPanel,
|
||||
isWallSelecting,
|
||||
wireStructureSelection,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Structures_Panel";
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Structures_Selection";
|
||||
import "../../B06_Section/B06_Section_UI_Style.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross.css";
|
||||
import "../../B06_Section/B06_Section_UI_Style_Cross_Controls.css";
|
||||
@@ -399,7 +399,7 @@ export async function mountCrossView(
|
||||
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
|
||||
const pipeAt = structuresPanel.currentChainage();
|
||||
const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`);
|
||||
if (mark) sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 목록 길은 뒤에 여럿
|
||||
sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 덮는 것 없으면 알약 · 강조 풂(152-5)
|
||||
structureSelection.syncInletStructure();
|
||||
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
||||
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
|
||||
|
||||
@@ -64,6 +64,26 @@ function standardEdges(edges: { left: Edge; right: Edge }, design: CrossDesign)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 노면공의 포장층만 `is-active`(152-5) — 범위 표식(종류 · 시작 · 끝)이 맞는 층. 겹친 노면공은 하나만.
|
||||
* `mark` 없음 = 모두 풂.
|
||||
*/
|
||||
export function highlightPavement(
|
||||
root: ParentNode,
|
||||
mark?: { type_id: string; start_m?: number | null; end_m?: number | null },
|
||||
): void {
|
||||
const [low, high] = [mark?.start_m ?? 0, mark?.end_m ?? 0].sort((a, b) => a - b);
|
||||
for (const layer of root.querySelectorAll<SVGElement>(".b06-chart__pavement")) {
|
||||
const { paveType, paveStart, paveEnd } = layer.dataset;
|
||||
const same =
|
||||
!!mark &&
|
||||
paveType === mark.type_id &&
|
||||
Math.abs(Number(paveStart) - low) < 0.01 &&
|
||||
Math.abs(Number(paveEnd) - high) < 0.01;
|
||||
layer.classList.toggle("is-active", same);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 포장층 — 노면선(= 포장 윗면) 아래로 두께만큼. 노면공 범위(`section.pavement`)면 그 두께 · 무늬 ·
|
||||
* 확폭 켬/끔(여러 건은 아래로 차례로 쌓음) · 없으면 카드 포장 켬일 때 표준 두께 채움만.
|
||||
@@ -92,6 +112,12 @@ export function appendPavementOverlay(
|
||||
const polygon = document.createElementNS(SVG_NS, "polygon");
|
||||
polygon.setAttribute("points", points);
|
||||
polygon.setAttribute("class", "b06-chart__pavement");
|
||||
if (entry) {
|
||||
// 범위 표식 — 고른 노면공의 포장층만 강조(152-5 · 겹친 노면공 가름)
|
||||
polygon.dataset.paveType = entry.type_id;
|
||||
polygon.dataset.paveStart = String(entry.start_m);
|
||||
polygon.dataset.paveEnd = String(entry.end_m);
|
||||
}
|
||||
const spec = entry?.spec;
|
||||
const mark = spec ? PAVE_MARKS[spec] : undefined;
|
||||
if (spec && mark) {
|
||||
|
||||
@@ -54,8 +54,6 @@ import {
|
||||
setAdjustSideSlots,
|
||||
} from "./B06_Section_UI_Adjust_Dock";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import type { RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec";
|
||||
|
||||
const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요.";
|
||||
@@ -161,6 +159,9 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
);
|
||||
|
||||
let markTypes: StructureType[] = [];
|
||||
/** 카드 누름이 덮어 여는 구조물 — 노면공(G군)은 뺌(152-5 · 목록 · 알약 고름은 id 길로 그대로). */
|
||||
const coverable = (entry: StructureInstance): boolean =>
|
||||
markTypes.find((type) => type.type_id === entry.type_id)?.group !== "G";
|
||||
/** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */
|
||||
let preferredId: string | null = null;
|
||||
/** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */
|
||||
@@ -518,7 +519,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
},
|
||||
selectionAt: (chainageM) => {
|
||||
const selection = { chainageM, structureId: null };
|
||||
return { chainageM, structureId: section.resolveAt(selection).structureId };
|
||||
return {
|
||||
chainageM,
|
||||
structureId: resolveSelection(selection, pipeFacilities, structures, coverable).structureId,
|
||||
};
|
||||
},
|
||||
prefer: (structureId) => {
|
||||
preferredId = structureId;
|
||||
@@ -529,171 +533,16 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
const selection: DesignSelection = { chainageM, structureId: preferredId };
|
||||
// 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에
|
||||
// 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳).
|
||||
const hit = resolveSelection(selection, pipeFacilities, structures);
|
||||
const hit = resolveSelection(selection, pipeFacilities, structures, coverable);
|
||||
if (hit.pipeChainageM !== null) {
|
||||
currentChainageM = hit.pipeChainageM;
|
||||
refreshSpecDefaults(hit.pipeChainageM);
|
||||
deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); // 측점선 없는 물넘이포장도(53-2)
|
||||
}
|
||||
section.selectAt(selection);
|
||||
// 폼은 위에서 푼 값으로 엶 — 공용 `selectAt` 은 노면공을 덮어 엶(152-5)
|
||||
if (hit.pipeChainageM !== null) section.selectPipeByChainage(hit.pipeChainageM);
|
||||
else section.selectById(hit.structureId);
|
||||
return { chainageM, structureId: hit.structureId };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그
|
||||
* 구조물의 **소유 측점** 시설을 좌측 폼에 올린다(2026-08-29 일원화 3단계).
|
||||
* 제어 객체의 select를 감싸기만 한다 — 조작·저장 경로는 그대로다.
|
||||
*/
|
||||
/** 지금 처리 중인 선택이 **도면에서 벽·구체를 찍은 것**인가 — 카드 선택 리스너가
|
||||
* 이 값으로 "카드만 고른 경우"를 가려낸다(2026-08-29 사용자: 측점 선택 때 구조물이
|
||||
* 자동으로 골라지면 안 된다). 같은 턴에서만 참이다. */
|
||||
let wallSelecting = false;
|
||||
|
||||
export function isWallSelecting(): boolean {
|
||||
return wallSelecting;
|
||||
}
|
||||
|
||||
function markWallSelecting(): void {
|
||||
wallSelecting = true;
|
||||
queueMicrotask(() => {
|
||||
wallSelecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
export function wireStructureSelection(
|
||||
stationControls: StationControls,
|
||||
detail: () => SectionDetailResponse | null,
|
||||
panel: B06StructuresPanel,
|
||||
/** 고른 부재를 세션에 남긴다(2026-09-04). 해제(null)는 부르지 않는다 — 조정창 닫기가
|
||||
* 카드(측점) 선택까지 지우면 안 된다. 측점 해제는 카드 선택 리스너가 따로 적는다. */
|
||||
remember?: (chainageM: number, key: string) => void,
|
||||
/** 폼에서 고른 벽을 카드 조정창에도 세움(54-30 · 목록 넘김 `applyStructurePick` 과 같은 차례). */
|
||||
refreshCard?: (chainageM: number) => void,
|
||||
/** 폼에 올린 시설로 보기를 옮김 — 목록에서 고른 길(54-47). */
|
||||
reveal?: (chainageM: number) => void,
|
||||
): { syncInletStructure: () => void } {
|
||||
const ownerChainageOf = (chainageM: number): number => {
|
||||
const sectionAt = detail()?.cross_sections.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < 0.01,
|
||||
);
|
||||
return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM;
|
||||
};
|
||||
|
||||
// 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에
|
||||
// 올릴 때는 조정값을 폼으로 되읽는다(2026-08-29 지시 5 병합).
|
||||
const sectionAt = (chainageM: number) =>
|
||||
detail()?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01);
|
||||
panel.facility.onInletStructureChange((value) => {
|
||||
const chainageM = panel.currentChainage();
|
||||
if (chainageM === null) return;
|
||||
stationControls.inletStructure.set(chainageM, value);
|
||||
});
|
||||
/** 폼에 올라온 시설의 유입측 형식을 폼 드롭다운에 되싣는다. */
|
||||
const syncInletStructure = (): void => {
|
||||
const chainageM = panel.currentChainage();
|
||||
const target = chainageM === null ? undefined : sectionAt(chainageM);
|
||||
if (target) panel.facility.setInletStructure(stationControls.inletStructure.valueFor(target));
|
||||
};
|
||||
|
||||
const origRevet = stationControls.revetOffset.select;
|
||||
stationControls.revetOffset.select = (chainageM, key) => {
|
||||
// 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1)
|
||||
const nested = wallSelecting;
|
||||
// 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1)
|
||||
if (key) markWallSelecting();
|
||||
origRevet(chainageM, key);
|
||||
if (key) remember?.(ownerChainageOf(chainageM), key);
|
||||
if (key) panel.showAtChainage(ownerChainageOf(chainageM));
|
||||
else if (!nested) {
|
||||
remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림
|
||||
panel.releasePipe();
|
||||
}
|
||||
if (key) syncInletStructure();
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
|
||||
// 관 상자 칸을 고치거나 고르면 그 부분을 고름 — 횡단에서 누른 것과 같은 길(54-30)
|
||||
const keyOf = (box: HTMLElement): RevetPick | null => {
|
||||
const id = box.dataset.specContainer;
|
||||
if (id === "inlet" || id === "outlet" || id === "pipe") return id;
|
||||
return id === "soil_add" ? `extra${Number(box.dataset.specIndex)}` : null;
|
||||
};
|
||||
const pickBox = (box: HTMLElement): void => {
|
||||
const chainageM = panel.currentChainage();
|
||||
const target = chainageM === null ? undefined : sectionAt(chainageM);
|
||||
const key = keyOf(box);
|
||||
if (!key || !target?.culvert || stationControls.revetOffset.highlightFor(target) === key)
|
||||
return;
|
||||
stationControls.revetOffset.select(target.chainage_m, key);
|
||||
refreshCard?.(target.chainage_m);
|
||||
};
|
||||
// 그 측점 카드 · 알약으로 감(목록에서 고른 길 · 54-47) — 같은 칸을 잇달아 고치면 한 번만(다시 그려도 칸 이름으로)
|
||||
const fieldOf = (el: HTMLElement): string => {
|
||||
const box = el.closest<HTMLElement>("fieldset[data-spec-container]");
|
||||
const item = el.closest<HTMLElement>("[data-spec-item]")?.dataset.specItem;
|
||||
return `${box?.dataset.specContainer}:${box?.dataset.specIndex}:${item}`;
|
||||
};
|
||||
let revealed: string | null = null;
|
||||
const revealAt = (chainageM: number, field: string | null): void => {
|
||||
if (field && field === revealed) return;
|
||||
revealed = field;
|
||||
reveal?.(chainageM);
|
||||
};
|
||||
panel.root.addEventListener("focusin", (event) => {
|
||||
if (fieldOf(event.target as HTMLElement) !== revealed) revealed = null;
|
||||
});
|
||||
panel.root.addEventListener("change", ({ target: el }) => {
|
||||
const box = (el as HTMLElement).closest<HTMLElement>("fieldset[data-spec-container]");
|
||||
const chainageM = panel.currentChainage();
|
||||
if (!box || chainageM === null) return;
|
||||
revealAt(chainageM, fieldOf(el as HTMLElement));
|
||||
// 단 수 칸은 고른 단을 지킴(단 수 바꿈 뒤 강조 유지) — 새 단은 다시 그린 뒤 아래에서 고름
|
||||
if (!(el as HTMLElement).closest('[data-spec-item="outlet.tiers"]')) pickBox(box);
|
||||
});
|
||||
// 상자가 새로 서면(단 늘림 · [추가]) 그 상자를 고름 — 같은 시설을 다시 그린 때만
|
||||
let painted: { chainageM: number | null; keys: Set<string> } | null = null;
|
||||
panel.root.addEventListener("specpaint", () => {
|
||||
const chainageM = panel.currentChainage();
|
||||
const boxes = [...panel.root.querySelectorAll<HTMLElement>("fieldset[data-spec-container]")];
|
||||
const before = painted?.chainageM === chainageM ? painted.keys : null;
|
||||
painted = { chainageM, keys: new Set(boxes.map(fieldOf)) };
|
||||
const born = before ? boxes.filter((box) => !before.has(fieldOf(box))).at(-1) : undefined;
|
||||
if (!born || chainageM === null) return;
|
||||
revealAt(chainageM, null);
|
||||
pickBox(born);
|
||||
});
|
||||
|
||||
const origFord = stationControls.ford.select;
|
||||
stationControls.ford.select = (chainageM, role) => {
|
||||
const nested = wallSelecting;
|
||||
if (role) markWallSelecting();
|
||||
origFord(chainageM, role);
|
||||
if (role) remember?.(chainageM, role);
|
||||
if (role) panel.showAtChainage(chainageM);
|
||||
else if (!nested) {
|
||||
remember?.(chainageM, "");
|
||||
panel.releasePipe();
|
||||
}
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다
|
||||
// (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1).
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
const origBox = stationControls.box.select;
|
||||
stationControls.box.select = (chainageM, role) => {
|
||||
const nested = wallSelecting;
|
||||
if (role) markWallSelecting();
|
||||
origBox(chainageM, role);
|
||||
if (role) remember?.(chainageM, role);
|
||||
if (role) panel.showAtChainage(chainageM);
|
||||
else if (!nested) {
|
||||
remember?.(chainageM, "");
|
||||
panel.releasePipe();
|
||||
}
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
return { syncInletStructure };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Structures_Selection.ts
|
||||
* 횡단도 선택 → 좌측 폼 연동 배선(`wireStructureSelection`) — `_Page_Structures_Panel.ts`
|
||||
* 에서 떼어 냄(700줄). 폼 · 목록 패널 자체는 그 파일 몫.
|
||||
* ========================================================================== */
|
||||
|
||||
import { refreshAdjustSlots } from "./B06_Section_UI_Adjust_Dock";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import type { RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel";
|
||||
|
||||
/**
|
||||
* 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그
|
||||
* 구조물의 **소유 측점** 시설을 좌측 폼에 올린다(2026-08-29 일원화 3단계).
|
||||
* 제어 객체의 select를 감싸기만 한다 — 조작·저장 경로는 그대로다.
|
||||
*/
|
||||
/** 지금 처리 중인 선택이 **도면에서 벽·구체를 찍은 것**인가 — 카드 선택 리스너가
|
||||
* 이 값으로 "카드만 고른 경우"를 가려낸다(2026-08-29 사용자: 측점 선택 때 구조물이
|
||||
* 자동으로 골라지면 안 된다). 같은 턴에서만 참이다. */
|
||||
let wallSelecting = false;
|
||||
|
||||
export function isWallSelecting(): boolean {
|
||||
return wallSelecting;
|
||||
}
|
||||
|
||||
function markWallSelecting(): void {
|
||||
wallSelecting = true;
|
||||
queueMicrotask(() => {
|
||||
wallSelecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
export function wireStructureSelection(
|
||||
stationControls: StationControls,
|
||||
detail: () => SectionDetailResponse | null,
|
||||
panel: B06StructuresPanel,
|
||||
/** 고른 부재를 세션에 남긴다(2026-09-04). 해제(null)는 부르지 않는다 — 조정창 닫기가
|
||||
* 카드(측점) 선택까지 지우면 안 된다. 측점 해제는 카드 선택 리스너가 따로 적는다. */
|
||||
remember?: (chainageM: number, key: string) => void,
|
||||
/** 폼에서 고른 벽을 카드 조정창에도 세움(54-30 · 목록 넘김 `applyStructurePick` 과 같은 차례). */
|
||||
refreshCard?: (chainageM: number) => void,
|
||||
/** 폼에 올린 시설로 보기를 옮김 — 목록에서 고른 길(54-47). */
|
||||
reveal?: (chainageM: number) => void,
|
||||
): { syncInletStructure: () => void } {
|
||||
const ownerChainageOf = (chainageM: number): number => {
|
||||
const sectionAt = detail()?.cross_sections.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < 0.01,
|
||||
);
|
||||
return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM;
|
||||
};
|
||||
|
||||
// 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에
|
||||
// 올릴 때는 조정값을 폼으로 되읽는다(2026-08-29 지시 5 병합).
|
||||
const sectionAt = (chainageM: number) =>
|
||||
detail()?.cross_sections.find((entry) => Math.abs(entry.chainage_m - chainageM) < 0.01);
|
||||
panel.facility.onInletStructureChange((value) => {
|
||||
const chainageM = panel.currentChainage();
|
||||
if (chainageM === null) return;
|
||||
stationControls.inletStructure.set(chainageM, value);
|
||||
});
|
||||
/** 폼에 올라온 시설의 유입측 형식을 폼 드롭다운에 되싣는다. */
|
||||
const syncInletStructure = (): void => {
|
||||
const chainageM = panel.currentChainage();
|
||||
const target = chainageM === null ? undefined : sectionAt(chainageM);
|
||||
if (target) panel.facility.setInletStructure(stationControls.inletStructure.valueFor(target));
|
||||
};
|
||||
|
||||
const origRevet = stationControls.revetOffset.select;
|
||||
stationControls.revetOffset.select = (chainageM, key) => {
|
||||
// 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1)
|
||||
const nested = wallSelecting;
|
||||
// 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1)
|
||||
if (key) markWallSelecting();
|
||||
origRevet(chainageM, key);
|
||||
if (key) remember?.(ownerChainageOf(chainageM), key);
|
||||
if (key) panel.showAtChainage(ownerChainageOf(chainageM));
|
||||
else if (!nested) {
|
||||
remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림
|
||||
panel.releasePipe();
|
||||
}
|
||||
if (key) syncInletStructure();
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
|
||||
// 관 상자 칸을 고치거나 고르면 그 부분을 고름 — 횡단에서 누른 것과 같은 길(54-30)
|
||||
const keyOf = (box: HTMLElement): RevetPick | null => {
|
||||
const id = box.dataset.specContainer;
|
||||
if (id === "inlet" || id === "outlet" || id === "pipe") return id;
|
||||
return id === "soil_add" ? `extra${Number(box.dataset.specIndex)}` : null;
|
||||
};
|
||||
const pickBox = (box: HTMLElement): void => {
|
||||
const chainageM = panel.currentChainage();
|
||||
const target = chainageM === null ? undefined : sectionAt(chainageM);
|
||||
const key = keyOf(box);
|
||||
if (!key || !target?.culvert || stationControls.revetOffset.highlightFor(target) === key)
|
||||
return;
|
||||
stationControls.revetOffset.select(target.chainage_m, key);
|
||||
refreshCard?.(target.chainage_m);
|
||||
};
|
||||
// 그 측점 카드 · 알약으로 감(목록에서 고른 길 · 54-47) — 같은 칸을 잇달아 고치면 한 번만(다시 그려도 칸 이름으로)
|
||||
const fieldOf = (el: HTMLElement): string => {
|
||||
const box = el.closest<HTMLElement>("fieldset[data-spec-container]");
|
||||
const item = el.closest<HTMLElement>("[data-spec-item]")?.dataset.specItem;
|
||||
return `${box?.dataset.specContainer}:${box?.dataset.specIndex}:${item}`;
|
||||
};
|
||||
let revealed: string | null = null;
|
||||
const revealAt = (chainageM: number, field: string | null): void => {
|
||||
if (field && field === revealed) return;
|
||||
revealed = field;
|
||||
reveal?.(chainageM);
|
||||
};
|
||||
panel.root.addEventListener("focusin", (event) => {
|
||||
if (fieldOf(event.target as HTMLElement) !== revealed) revealed = null;
|
||||
});
|
||||
panel.root.addEventListener("change", ({ target: el }) => {
|
||||
const box = (el as HTMLElement).closest<HTMLElement>("fieldset[data-spec-container]");
|
||||
const chainageM = panel.currentChainage();
|
||||
if (!box || chainageM === null) return;
|
||||
revealAt(chainageM, fieldOf(el as HTMLElement));
|
||||
// 단 수 칸은 고른 단을 지킴(단 수 바꿈 뒤 강조 유지) — 새 단은 다시 그린 뒤 아래에서 고름
|
||||
if (!(el as HTMLElement).closest('[data-spec-item="outlet.tiers"]')) pickBox(box);
|
||||
});
|
||||
// 상자가 새로 서면(단 늘림 · [추가]) 그 상자를 고름 — 같은 시설을 다시 그린 때만
|
||||
let painted: { chainageM: number | null; keys: Set<string> } | null = null;
|
||||
panel.root.addEventListener("specpaint", () => {
|
||||
const chainageM = panel.currentChainage();
|
||||
const boxes = [...panel.root.querySelectorAll<HTMLElement>("fieldset[data-spec-container]")];
|
||||
const before = painted?.chainageM === chainageM ? painted.keys : null;
|
||||
painted = { chainageM, keys: new Set(boxes.map(fieldOf)) };
|
||||
const born = before ? boxes.filter((box) => !before.has(fieldOf(box))).at(-1) : undefined;
|
||||
if (!born || chainageM === null) return;
|
||||
revealAt(chainageM, null);
|
||||
pickBox(born);
|
||||
});
|
||||
|
||||
const origFord = stationControls.ford.select;
|
||||
stationControls.ford.select = (chainageM, role) => {
|
||||
const nested = wallSelecting;
|
||||
if (role) markWallSelecting();
|
||||
origFord(chainageM, role);
|
||||
if (role) remember?.(chainageM, role);
|
||||
if (role) panel.showAtChainage(chainageM);
|
||||
else if (!nested) {
|
||||
remember?.(chainageM, "");
|
||||
panel.releasePipe();
|
||||
}
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다
|
||||
// (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1).
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
const origBox = stationControls.box.select;
|
||||
stationControls.box.select = (chainageM, role) => {
|
||||
const nested = wallSelecting;
|
||||
if (role) markWallSelecting();
|
||||
origBox(chainageM, role);
|
||||
if (role) remember?.(chainageM, role);
|
||||
if (role) panel.showAtChainage(chainageM);
|
||||
else if (!nested) {
|
||||
remember?.(chainageM, "");
|
||||
panel.releasePipe();
|
||||
}
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
return { syncInletStructure };
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
HaulEquipmentLimit,
|
||||
SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
|
||||
import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
} from "./B06_Section_UI_Section_Common";
|
||||
import type { SectionViewController } from "./B06_Section_UI_Section_View_Types";
|
||||
import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice";
|
||||
import { highlightPavement } from "./B06_Section_UI_Cross_Design_Surface";
|
||||
import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile";
|
||||
import type { DesignPageShared } from "../A00_Common/design/design_shared";
|
||||
|
||||
@@ -231,23 +232,33 @@ export function createSectionView(
|
||||
if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights;
|
||||
|
||||
/**
|
||||
* 고른 구간형 구조물(노면공 등)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1).
|
||||
* 고른 구간형 구조물(옹벽 · 독립 흙막이 · 사토장)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1).
|
||||
* 노면공(G군)은 테두리 대신 범위 카드 안 그 노면공의 포장층만 강조(152-5) — 겹친 노면공은 범위 표식으로 하나만.
|
||||
* 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다.
|
||||
*/
|
||||
let spanMarks: ReadonlyArray<StructureInstance> = [];
|
||||
let spanTypes: ReadonlyArray<StructureType> = [];
|
||||
let spanMarkId: string | null = null;
|
||||
const inSpan = (section: CrossSection): boolean => {
|
||||
const mark = spanMarkId ? spanMarks.find((entry) => entry.structure_id === spanMarkId) : null;
|
||||
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;
|
||||
const at = section.chainage_m;
|
||||
return at >= Math.min(start, end) - 0.01 && at <= Math.max(start, end) + 0.01;
|
||||
};
|
||||
const applySpan = (card: HTMLElement, section: CrossSection): void => {
|
||||
const mark = spanMarkId
|
||||
? spanMarks.find((entry) => entry.structure_id === spanMarkId)
|
||||
: undefined;
|
||||
const span = inSpan(section, mark);
|
||||
const pave = span && spanTypes.find((type) => type.type_id === mark?.type_id)?.group === "G";
|
||||
card.classList.toggle("b06-cross-card--span", span && !pave);
|
||||
highlightPavement(card, pave ? mark : undefined);
|
||||
};
|
||||
const syncSpanHighlights = (): void => {
|
||||
for (const section of currentDetail?.cross_sections ?? [])
|
||||
document
|
||||
.getElementById(`cross-${section.station_id}`)
|
||||
?.classList.toggle("b06-cross-card--span", inSpan(section));
|
||||
for (const section of currentDetail?.cross_sections ?? []) {
|
||||
const card = document.getElementById(`cross-${section.station_id}`);
|
||||
if (card) applySpan(card, section);
|
||||
}
|
||||
};
|
||||
|
||||
/** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */
|
||||
@@ -349,7 +360,7 @@ export function createSectionView(
|
||||
plotBase,
|
||||
cutSlope,
|
||||
);
|
||||
card.classList.toggle("b06-cross-card--span", inSpan(section));
|
||||
applySpan(card, section);
|
||||
return card;
|
||||
};
|
||||
|
||||
@@ -543,6 +554,7 @@ export function createSectionView(
|
||||
},
|
||||
setStructureMarks(structures, types) {
|
||||
spanMarks = structures;
|
||||
spanTypes = types;
|
||||
syncSpanHighlights();
|
||||
profile.setMarks(structures, types);
|
||||
// 검증용 훅 — 알약 레인이 무엇을 받았는지 화면 밖에서 수치로 본다
|
||||
|
||||
@@ -200,6 +200,13 @@
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* 고른 노면공의 포장층(152-5) — 범위 카드마다 · 무늬 채움은 inline 이라 무늬 없는 층만 채움이 바뀌고 테두리로 강조 */
|
||||
.b06-chart__pavement.is-active {
|
||||
fill: color-mix(in srgb, var(--color-danger) 30%, transparent);
|
||||
stroke: var(--color-danger);
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
|
||||
/* 노면공 포장사양 무늬(88): 포장 박스 채움 = 바탕 + 무늬 — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌 */
|
||||
.b06-chart__pave-bg {
|
||||
fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent);
|
||||
|
||||
@@ -146,6 +146,8 @@ _COLORS = {
|
||||
"emd": "#2f7d4f",
|
||||
"sgg": "#a63d3d",
|
||||
}
|
||||
# 글 · 지시선만 조각 색과 다름 — IP 글 = 기준 형상(노선 중심선) 색(153-2)
|
||||
_TEXT_COLORS = {"ip": _COLORS["centerline"]}
|
||||
# 행정 · 지적 경계 (선굵기, 파선)
|
||||
_BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = {
|
||||
"parcel": (1, None),
|
||||
@@ -274,7 +276,7 @@ def _piece_entities(
|
||||
"label": label,
|
||||
"size": size,
|
||||
"layer": layer,
|
||||
"color": color,
|
||||
"color": _TEXT_COLORS.get(piece, color),
|
||||
"anchor": anchor,
|
||||
"candidates": spots,
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
|
||||
// 표준도 제원 칸·[여기서 고치기]는 2026-09-13 B08 「구조물도」 탭으로 이관(PLAN 3장 ④).
|
||||
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
|
||||
void fillCrossLayout(drawing);
|
||||
if (drawing.kind !== "cross") {
|
||||
infoPanelHost.replaceChildren();
|
||||
return;
|
||||
@@ -144,28 +145,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
// 장은 id 가 `cross_s…`(여러 측점 묶음), 측점 도면은 `cross_…m` 이다. `chainage_m` 은
|
||||
// 장에도 첫 측점 값이 실려 오므로 그것으로는 갈리지 않는다.
|
||||
const scope = isCrossSheet(drawing) ? "sheet" : "station";
|
||||
const panel = buildDesignInfoPanel(drawing.label, response.design ?? null, scope);
|
||||
infoPanelHost.replaceChildren(panel);
|
||||
if (scope === "sheet") void appendCrossLayout(drawing, panel);
|
||||
infoPanelHost.replaceChildren(
|
||||
buildDesignInfoPanel(drawing.label, response.design ?? null, scope),
|
||||
);
|
||||
};
|
||||
|
||||
/** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. */
|
||||
const appendCrossLayout = async (drawing: DesignDrawingItem, panel: HTMLElement) => {
|
||||
/** 장 배치 칸 자리(153-1) — 횡단도 묶음 맨 아래 · 정보 칸을 다시 그려도 남음. */
|
||||
const crossLayoutSlot = document.createElement("div");
|
||||
crossLayoutSlot.className = "b07-cross-layout-slot";
|
||||
let crossLayoutRequest: Promise<CrossLayout> | undefined;
|
||||
|
||||
/** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. 장 아니면 기본값 · [이 장] 꺼짐. */
|
||||
const fillCrossLayout = async (drawing: DesignDrawingItem) => {
|
||||
if (!projectId) return;
|
||||
const number = drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1;
|
||||
let layout: CrossLayout;
|
||||
try {
|
||||
layout = await fetchCrossLayout(projectId);
|
||||
layout = await (crossLayoutRequest ??= fetchCrossLayout(projectId));
|
||||
} catch {
|
||||
crossLayoutRequest = undefined;
|
||||
return; // 배치 칸만 빠짐 — 도면 보기는 그대로
|
||||
}
|
||||
if (currentDrawing?.id !== drawing.id || !panel.isConnected) return;
|
||||
if (currentDrawing?.id !== drawing.id) return;
|
||||
const number = isCrossSheet(drawing)
|
||||
? drawings.filter(isCrossSheet).findIndex((item) => item.id === drawing.id) + 1
|
||||
: 0;
|
||||
const save = async (next: CrossLayout) => {
|
||||
if (!mayDiscardEdits()) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
await saveCrossLayout(projectId, next);
|
||||
reopenCrossSheet = number;
|
||||
reopenCrossSheet = number || undefined;
|
||||
reopenDrawingId = number ? undefined : currentDrawing?.id;
|
||||
cad.destroy();
|
||||
void renderB07DesignDetail(root);
|
||||
} catch (error) {
|
||||
@@ -174,10 +184,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
};
|
||||
panel.append(
|
||||
crossLayoutSlot.replaceChildren(
|
||||
buildCrossLayoutControls(
|
||||
layout.sheets[String(number)] ?? layout.default,
|
||||
(grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } }),
|
||||
(number && layout.sheets[String(number)]) || layout.default,
|
||||
number
|
||||
? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } })
|
||||
: null,
|
||||
(grid) => void save({ default: grid, sheets: {} }),
|
||||
),
|
||||
);
|
||||
@@ -517,6 +529,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
crossLayoutSlot,
|
||||
);
|
||||
drawingListEl = drawingPanel;
|
||||
const confirmActions = document.createElement("div");
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { CrossDesignInfo, DesignDrawingItem } from "./B07_DesignDetail_Api_Fetch";
|
||||
import { buildPlanSection } from "./B07_DesignDetail_UI_PlanKinds";
|
||||
@@ -46,6 +46,8 @@ export function buildDrawingSidePanel(
|
||||
errorMessage?: string,
|
||||
devBypass = false,
|
||||
plan?: PlanManageControls,
|
||||
/** 횡단도 묶음 맨 아래에 붙일 자리(153-1 장 배치 칸) — 내용은 화면 조립이 도면을 열 때마다 채움. */
|
||||
crossTools?: HTMLElement,
|
||||
): HTMLDivElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b07-drawing-list";
|
||||
@@ -147,6 +149,7 @@ export function buildDrawingSidePanel(
|
||||
// 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다.
|
||||
section.append(drawingButton(drawing, drawing.label));
|
||||
}
|
||||
if (cross && crossTools) section.append(crossTools);
|
||||
panel.append(section);
|
||||
}
|
||||
attachCollapsible(panel);
|
||||
@@ -172,54 +175,52 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean {
|
||||
return drawing.kind === "cross" && drawing.id.startsWith("cross_s");
|
||||
}
|
||||
|
||||
/** 횡단 장 배치 칸(140-3) — 「배치 열 [4] × 행 [4] [적용]」 + [모든 장 기본]. */
|
||||
/**
|
||||
* 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」.
|
||||
* `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔.
|
||||
*/
|
||||
export function buildCrossLayoutControls(
|
||||
grid: [number, number],
|
||||
onApply: (grid: [number, number]) => void,
|
||||
onApply: ((grid: [number, number]) => void) | null,
|
||||
onAllDefault: (grid: [number, number]) => void,
|
||||
): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.className = "b07-info__layout";
|
||||
const number = (value: number, label: string): HTMLInputElement => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "1";
|
||||
input.max = "10";
|
||||
input.step = "1";
|
||||
input.value = String(value);
|
||||
input.ariaLabel = label;
|
||||
return input;
|
||||
};
|
||||
const cols = number(grid[0], "열");
|
||||
box.className = "b07-cross-layout";
|
||||
const number = (value: number, label: string) =>
|
||||
createInputField({
|
||||
label,
|
||||
type: "number",
|
||||
value: String(value),
|
||||
min: 1,
|
||||
max: 10,
|
||||
compact: true,
|
||||
});
|
||||
const cols = number(grid[0], "배치 열");
|
||||
const rows = number(grid[1], "행");
|
||||
const read = (): [number, number] | null => {
|
||||
const value: [number, number] = [Number(cols.value), Number(rows.value)];
|
||||
const value: [number, number] = [Number(cols.input.value), Number(rows.input.value)];
|
||||
return value.every((n) => Number.isInteger(n) && n >= 1 && n <= 10) ? value : null;
|
||||
};
|
||||
const action = (text: string, title: string, run: (grid: [number, number]) => void) => {
|
||||
const node = document.createElement("button");
|
||||
node.type = "button";
|
||||
node.className = "b07-plan-tools__button";
|
||||
node.textContent = text;
|
||||
node.title = title;
|
||||
node.addEventListener("click", () => {
|
||||
const value = read();
|
||||
if (value) run(value);
|
||||
else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error");
|
||||
const action = (label: string, title: string, run: ((grid: [number, number]) => void) | null) => {
|
||||
const button = createButton({
|
||||
label,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: !run,
|
||||
onClick: () => {
|
||||
const value = read();
|
||||
if (value) run?.(value);
|
||||
else showToast("열 · 행은 1~10 사이 정수로 적어 주세요.", "error");
|
||||
},
|
||||
});
|
||||
return node;
|
||||
button.title = title;
|
||||
return button;
|
||||
};
|
||||
const label = document.createElement("span");
|
||||
label.textContent = "배치 열";
|
||||
const times = document.createElement("span");
|
||||
times.textContent = "× 행";
|
||||
box.append(
|
||||
label,
|
||||
cols,
|
||||
times,
|
||||
rows,
|
||||
action("적용", "이 장만 이 배치로", onApply),
|
||||
action("모든 장 기본", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
|
||||
cols.root,
|
||||
rows.root,
|
||||
action("이 장", "이 장만 이 배치로", onApply),
|
||||
action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
|
||||
);
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -225,18 +225,41 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 횡단 장 배치(140-3) — 배치 열 [4] × 행 [4] [적용] [모든 장 기본] */
|
||||
.b07-info__layout {
|
||||
/* 횡단 장 배치(153-1) — 횡단도 묶음 맨 아래 전체 폭: 배치 열 [4] 행 [4] [이 장] [모든 장].
|
||||
좌측이 182px 까지 줄어드니 줄바꿈 허용 · 공용 칸의 오류 자리는 접음. */
|
||||
.b07-cross-layout-slot {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.b07-cross-layout-slot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b07-cross-layout {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
gap: var(--spacing-4) var(--spacing-8);
|
||||
}
|
||||
|
||||
.b07-info__layout input {
|
||||
width: 44px;
|
||||
.b07-cross-layout > .ui-field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-field__label {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-input {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.b07-cross-layout .ui-field__error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 확정을 건너뛴 개발 상태 알림 — 눈에 띄되 도면 목록을 밀어내지 않게 한 줄만. */
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Point } from '@flatten-js/core';
|
||||
import { compact, round } from 'es-toolkit';
|
||||
import { Actor } from 'xstate';
|
||||
import {
|
||||
PINCH_ZOOM_EXPONENT,
|
||||
SNAP_POINT_DISTANCE,
|
||||
WHEEL_LINE_PX,
|
||||
WHEEL_PAGE_PX,
|
||||
WHEEL_ZOOM_EXPONENT,
|
||||
} from '../App.consts.ts';
|
||||
import { SNAP_POINT_DISTANCE } from '../App.consts.ts';
|
||||
import { MouseButton } from '../App.types.ts';
|
||||
import {
|
||||
describeCommand,
|
||||
@@ -66,6 +60,7 @@ import {
|
||||
isPlainSpace,
|
||||
} from './ime-sink.ts';
|
||||
import { drawInputField } from './input-field-draw.ts';
|
||||
import { wheelZoom } from './input-wheel.ts';
|
||||
import {
|
||||
ABSOLUTE_POINT_REGEXP,
|
||||
ABSOLUTE_POLAR_REGEXP,
|
||||
@@ -277,30 +272,8 @@ export class InputController {
|
||||
public handleMouseWheel(evt: WheelEvent) {
|
||||
// 확대는 도면만 — 트랙패드 핀치(ctrl+휠)가 브라우저 전체를 키우던 것을 막는다.
|
||||
evt.preventDefault();
|
||||
const drawController = getScreenCanvasDrawController();
|
||||
// deltaY 단위를 픽셀로 맞춘다 — 브라우저에 따라 줄(1)·쪽(2) 단위로 오기도 한다.
|
||||
const unitPx = evt.deltaMode === 1 ? WHEEL_LINE_PX : evt.deltaMode === 2 ? WHEEL_PAGE_PX : 1;
|
||||
const deltaX = evt.deltaX * unitPx;
|
||||
const deltaY = evt.deltaY * unitPx;
|
||||
|
||||
// 핀치(ctrl+휠)는 기기와 무관하게 확대.
|
||||
if (evt.ctrlKey) {
|
||||
if (deltaY !== 0) drawController.zoomScreen(-deltaY, PINCH_ZOOM_EXPONENT);
|
||||
return;
|
||||
}
|
||||
|
||||
// 트랙패드 두 손가락 이동은 팬 — 문서를 스크롤하듯 화면이 손가락 반대로 간다.
|
||||
if (!isMouseWheel(evt)) {
|
||||
drawController.panScreen(-deltaX, deltaY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaY === 0) {
|
||||
return; // We can't zoom by zero delta
|
||||
}
|
||||
// Aislo: wheel direction is inverted on purpose - pulling the wheel zooms in, pushing
|
||||
// zooms out, matching the B04/B05 maps and the B06 cross sections (2026-08-02).
|
||||
drawController.zoomScreen(-deltaY, WHEEL_ZOOM_EXPONENT);
|
||||
const zoom = wheelZoom(evt);
|
||||
if (zoom) getScreenCanvasDrawController().zoomScreen(zoom.delta, zoom.exponent);
|
||||
}
|
||||
|
||||
public handleMouseDown(evt: MouseEvent) {
|
||||
@@ -643,15 +616,3 @@ export class InputController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마우스 휠인지 트랙패드인지 가른다. 마우스 휠은 한 칸이 정해진 크기(Chrome 100px,
|
||||
* 일부 브라우저 120px)로 딱 떨어지고 가로 델타가 없다. 트랙패드는 손가락이 움직인
|
||||
* 만큼 잔 델타를 보내고 가로 델타도 함께 온다. 줄·쪽 단위(deltaMode≠0)는 휠뿐이다.
|
||||
*/
|
||||
function isMouseWheel(evt: WheelEvent): boolean {
|
||||
if (evt.deltaMode !== 0) return true;
|
||||
if (evt.deltaX !== 0) return false;
|
||||
const step = Math.abs(evt.deltaY);
|
||||
return step !== 0 && (step % 100 === 0 || step % 120 === 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PINCH_ZOOM_EXPONENT,
|
||||
WHEEL_LINE_PX,
|
||||
WHEEL_PAGE_PX,
|
||||
WHEEL_ZOOM_EXPONENT,
|
||||
} from '../App.consts.ts';
|
||||
import { wheelZoom } from './input-wheel.ts';
|
||||
|
||||
const wheel = (deltaY: number, deltaMode = 0, ctrlKey = false) =>
|
||||
wheelZoom({ deltaY, deltaMode, ctrlKey });
|
||||
|
||||
describe('CAD 휠 = 늘 줌(153-3)', () => {
|
||||
it('100 · 120 배수가 아닌 델타(확대율 90 % · 110 % · 트랙패드 잔 델타)도 줌', () => {
|
||||
for (const deltaY of [100, 120, 111.1, 90.9, 4, -100, -111.1, -4]) {
|
||||
expect(wheel(deltaY)).toEqual({ delta: -deltaY, exponent: WHEEL_ZOOM_EXPONENT });
|
||||
}
|
||||
});
|
||||
|
||||
it('줄 · 쪽 단위는 픽셀로 환산', () => {
|
||||
expect(wheel(3, 1)).toEqual({ delta: -3 * WHEEL_LINE_PX, exponent: WHEEL_ZOOM_EXPONENT });
|
||||
expect(wheel(-1, 2)).toEqual({ delta: WHEEL_PAGE_PX, exponent: WHEEL_ZOOM_EXPONENT });
|
||||
});
|
||||
|
||||
it('ctrl+휠(핀치)은 핀치 지수', () => {
|
||||
expect(wheel(-7, 0, true)).toEqual({ delta: 7, exponent: PINCH_ZOOM_EXPONENT });
|
||||
});
|
||||
|
||||
it('델타 0 은 안 움직임', () => {
|
||||
expect(wheel(0)).toBeNull();
|
||||
expect(wheel(0, 0, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
PINCH_ZOOM_EXPONENT,
|
||||
WHEEL_LINE_PX,
|
||||
WHEEL_PAGE_PX,
|
||||
WHEEL_ZOOM_EXPONENT,
|
||||
} from '../App.consts.ts';
|
||||
|
||||
/**
|
||||
* 휠 한 번 → 화면 확대(`zoomScreen` 인자). 휠은 늘 줌 · 커서 자리 고정(153-3) — 마우스 · 트랙패드를
|
||||
* 가르지 않는다(옛 판정은 확대율 ≠ 100 % 등에서 휠을 팬으로 오판). 팬 = 가운데 단추 끌기.
|
||||
* null = 델타 0 이라 안 움직임.
|
||||
*/
|
||||
export function wheelZoom(
|
||||
evt: Pick<WheelEvent, 'deltaMode' | 'deltaY' | 'ctrlKey'>
|
||||
): { delta: number; exponent: number } | null {
|
||||
// deltaY 단위를 픽셀로 맞춘다 — 브라우저에 따라 줄(1)·쪽(2) 단위로 오기도 한다.
|
||||
const unitPx = evt.deltaMode === 1 ? WHEEL_LINE_PX : evt.deltaMode === 2 ? WHEEL_PAGE_PX : 1;
|
||||
const deltaY = evt.deltaY * unitPx;
|
||||
if (deltaY === 0) return null;
|
||||
// Aislo: wheel direction is inverted on purpose - pulling the wheel zooms in, pushing
|
||||
// zooms out, matching the B04/B05 maps and the B06 cross sections (2026-08-02).
|
||||
// 핀치(ctrl+휠)는 델타가 잘아 지수만 크게.
|
||||
return { delta: -deltaY, exponent: evt.ctrlKey ? PINCH_ZOOM_EXPONENT : WHEEL_ZOOM_EXPONENT };
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/* 152-5 노면공 강조 헬퍼 — 포장층 범위 표식 · 고른 노면공 포장층만 is-active · 카드 누름(③ 덮는 구조물)에서 노면공 뺌을
|
||||
* Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · SVG 그릇은 쓰는 만큼만 흉내.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.attrs = {};
|
||||
this.style = {};
|
||||
this._classes = new Set();
|
||||
const self = this;
|
||||
this.classList = {
|
||||
contains: (name) => self._classes.has(name),
|
||||
toggle: (name, on) => (on ? self._classes.add(name) : self._classes.delete(name)),
|
||||
};
|
||||
}
|
||||
setAttribute(key, value) {
|
||||
this.attrs[key] = String(value);
|
||||
if (key === "class") this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
const name = selector.slice(1);
|
||||
const all = (node) => node.children.flatMap((kid) => [kid, ...all(kid)]);
|
||||
return all(this).filter((kid) => kid.classList.contains(name));
|
||||
}
|
||||
}
|
||||
globalThis.document = { createElementNS: (_, tag) => new FakeNode(tag) };
|
||||
|
||||
function loadTs(file, stubs = {}) {
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (stubs[request]) return stubs[request];
|
||||
throw new Error(`stub 없음: ${request}`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const surface = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Surface.ts"));
|
||||
const selection = loadTs(path.join(ROOT, "A00_Common", "design", "design_selection.ts"), {
|
||||
"../b_page_state": { readState: () => null, writeState: () => {} },
|
||||
});
|
||||
|
||||
const out = {};
|
||||
|
||||
// 포장층 — 같은 측점 노면공 둘(겹침) · 범위 표식
|
||||
const svg = new FakeNode("svg");
|
||||
const edge = (offset) => ({ offset_m: offset, elevation_m: 100 });
|
||||
const design = { carriageway_edges: { left: edge(-2), right: edge(2) }, pavement_thickness_m: 0.2 };
|
||||
const entry = (start, end) => ({
|
||||
type_id: "pavement_concrete",
|
||||
spec: null,
|
||||
thickness_m: 0.2,
|
||||
start_m: start,
|
||||
end_m: end,
|
||||
widening_on: true,
|
||||
});
|
||||
surface.appendPavementOverlay(
|
||||
svg,
|
||||
design,
|
||||
(v) => v,
|
||||
(v) => v,
|
||||
[entry(0, 40), entry(20, 60)],
|
||||
);
|
||||
const layers = () => svg.querySelectorAll(".b06-chart__pavement");
|
||||
const active = () => layers().map((layer) => layer.classList.contains("is-active"));
|
||||
out.marks = layers().map((layer) => ({ ...layer.dataset }));
|
||||
surface.highlightPavement(svg, { type_id: "pavement_concrete", start_m: 60, end_m: 20 });
|
||||
out.pickSecond = active();
|
||||
surface.highlightPavement(svg, { type_id: "gravel_surfacing", start_m: 20, end_m: 60 });
|
||||
out.otherType = active();
|
||||
surface.highlightPavement(svg, { type_id: "pavement_concrete", start_m: 0, end_m: 40 });
|
||||
out.pickFirst = active();
|
||||
surface.highlightPavement(svg, undefined);
|
||||
out.cleared = active();
|
||||
|
||||
// 카드 누름 — ③ 덮는 구조물에서 노면공 뺌 · ① id 길은 그대로
|
||||
const pave = { structure_id: "pave", type_id: "pavement_concrete", start_m: 0, end_m: 40 };
|
||||
const wall = { structure_id: "wall", type_id: "retaining_wall", start_m: 10, end_m: 30 };
|
||||
const coverable = (s) => s.type_id !== "pavement_concrete";
|
||||
const resolve = (sel, list, fn) => selection.resolveSelection(sel, [], list, fn);
|
||||
out.cover = {
|
||||
default: resolve({ chainageM: 5, structureId: null }, [pave]).structureId,
|
||||
skipped: resolve({ chainageM: 5, structureId: null }, [pave], coverable).structureId,
|
||||
wallFirst: resolve({ chainageM: 20, structureId: null }, [pave, wall], coverable).structureId,
|
||||
byId: resolve({ chainageM: 5, structureId: "pave" }, [pave], coverable).structureId,
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,165 @@
|
||||
/* 153-1 B07 횡단 장 배치 칸 헬퍼 — 좌측 「횡단도」 묶음 맨 아래 자리 · 공용 입력칸 · 작은 단추를
|
||||
* Node 로 돌려 표준출력 JSON 한 벌로. TS 는 그 자리에서 트랜스파일 · 화면 그릇(DOM)은 쓰는 만큼만 흉내 ·
|
||||
* 공용 단추 · 입력칸은 진짜(`ui_template_elements`) · 알림만 기록용 가짜.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B07 = path.join(ROOT, "B07_DesignDetail");
|
||||
const UI = path.join(ROOT, "ui_template");
|
||||
const cache = new Map();
|
||||
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.attrs = {};
|
||||
this.listeners = {};
|
||||
this._text = "";
|
||||
this._classes = new Set();
|
||||
this.value = "";
|
||||
this.disabled = false;
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...names) => names.forEach((n) => self._classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
|
||||
contains: (name) => self._classes.has(name),
|
||||
};
|
||||
}
|
||||
set className(value) {
|
||||
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this._classes].join(" ");
|
||||
}
|
||||
set textContent(value) {
|
||||
this._text = String(value);
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
setAttribute(key, value) {
|
||||
this.attrs[key] = String(value);
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => this.children.push(k));
|
||||
}
|
||||
replaceChildren(...kids) {
|
||||
this.children = [...kids];
|
||||
}
|
||||
addEventListener(type, handler) {
|
||||
(this.listeners[type] ||= []).push(handler);
|
||||
}
|
||||
click() {
|
||||
if (!this.disabled) (this.listeners.click || []).forEach((handler) => handler({}));
|
||||
}
|
||||
}
|
||||
const all = (node) => [node, ...node.children.flatMap(all)];
|
||||
const byClass = (node, name) => all(node).filter((n) => n.classList.contains(name));
|
||||
|
||||
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
|
||||
|
||||
const toasts = [];
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request === "@ui/ui_template_elements")
|
||||
return {
|
||||
...loadTs(path.join(UI, "ui_template_elements.ts")),
|
||||
showToast: (message, kind) => toasts.push([kind, message]),
|
||||
};
|
||||
if (request === "@ui/ui_template_collapsible") return { attachCollapsible: () => {} };
|
||||
if (request === "@ui/ui_template_locale")
|
||||
return {
|
||||
currentLanguageIndex: 0,
|
||||
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
|
||||
};
|
||||
if (request.startsWith(".")) {
|
||||
const target = path.join(path.dirname(full), `${request}.ts`);
|
||||
return fs.existsSync(target) ? loadTs(target) : {};
|
||||
}
|
||||
return {}; // 이 시험이 안 부르는 바깥 길(API · 설정 · CAD)
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const { buildDrawingSidePanel, buildCrossLayoutControls } = loadTs(
|
||||
path.join(B07, "B07_DesignDetail_UI_Panels.ts"),
|
||||
);
|
||||
|
||||
const out = {};
|
||||
const slot = new FakeNode("div");
|
||||
slot.className = "b07-cross-layout-slot";
|
||||
const panel = buildDrawingSidePanel(
|
||||
[
|
||||
{ id: "cross_standard", kind: "cross_standard", label: "표준 횡단면도", confirmed: false },
|
||||
{ id: "cross_s1", kind: "cross", label: "1장", confirmed: false },
|
||||
{ id: "cross_s2", kind: "cross", label: "2장", confirmed: true },
|
||||
],
|
||||
() => {},
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
slot,
|
||||
);
|
||||
const cross = all(panel).find((n) => n.dataset.kind === "cross");
|
||||
out.slotLast = cross.children.at(-1) === slot;
|
||||
out.slotCount = all(panel).filter((n) => n === slot).length;
|
||||
|
||||
// 장 — [이 장] 켜짐 · 값 바꿔 누름
|
||||
const applied = [];
|
||||
const defaults = [];
|
||||
slot.replaceChildren(
|
||||
buildCrossLayoutControls(
|
||||
[3, 4],
|
||||
(grid) => applied.push(grid),
|
||||
(grid) => defaults.push(grid),
|
||||
),
|
||||
);
|
||||
const box = slot.children[0];
|
||||
const buttons = () => all(slot).filter((n) => n.tagName === "BUTTON");
|
||||
const inputs = () => all(slot).filter((n) => n.tagName === "INPUT");
|
||||
out.sheet = {
|
||||
boxClass: box.className,
|
||||
labels: byClass(slot, "ui-field__label").map((n) => n.textContent),
|
||||
inputs: inputs().map((n) => ({
|
||||
value: n.value,
|
||||
type: n.attrs.type,
|
||||
min: n.attrs.min,
|
||||
max: n.attrs.max,
|
||||
cls: n.className,
|
||||
})),
|
||||
buttons: buttons().map((n) => ({ text: n.textContent, cls: n.className, disabled: n.disabled })),
|
||||
};
|
||||
inputs()[0].value = "2";
|
||||
inputs()[1].value = "5";
|
||||
buttons()[0].click();
|
||||
buttons()[1].click();
|
||||
inputs()[0].value = "11";
|
||||
buttons()[0].click();
|
||||
out.sheetClicks = { applied, defaults, toasts: [...toasts] };
|
||||
|
||||
// 장 아님 — [이 장] 꺼짐 · [모든 장]은 됨
|
||||
const defaults2 = [];
|
||||
slot.replaceChildren(buildCrossLayoutControls([4, 4], null, (grid) => defaults2.push(grid)));
|
||||
buttons()[0].click();
|
||||
buttons()[1].click();
|
||||
out.notSheet = {
|
||||
disabled: buttons().map((n) => n.disabled),
|
||||
defaults: defaults2,
|
||||
values: inputs().map((n) => n.value),
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -22,19 +22,24 @@ VIEW = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
|
||||
|
||||
def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길에서_칠한다():
|
||||
assert VIEW.count("b06-cross-card--span") == 2 # syncSpanHighlights · buildCrossCard
|
||||
# applySpan 한 곳 — syncSpanHighlights · buildCrossCard 가 부름
|
||||
assert VIEW.count("b06-cross-card--span") == 1
|
||||
assert re.search(
|
||||
r"selectMark: \(structureId, span = true\) => \{\s+(//[^\n]*\s+)?spanMarkId = span \? structureId : null;",
|
||||
VIEW,
|
||||
)
|
||||
assert re.search(r"spanMarks = structures;\s+syncSpanHighlights\(\);", VIEW)
|
||||
assert 'card.classList.toggle("b06-cross-card--span", inSpan(section))' in VIEW
|
||||
assert re.search(
|
||||
r"spanMarks = structures;\s+spanTypes = types;\s+syncSpanHighlights\(\);", VIEW
|
||||
)
|
||||
assert VIEW.count("applySpan(card, section);") == 2
|
||||
# 152-5 노면공(G군)은 테두리 대신 포장층
|
||||
assert 'card.classList.toggle("b06-cross-card--span", span && !pave);' in VIEW
|
||||
|
||||
|
||||
def test_측점_고름은_카드_하나_목록_고름은_여럿():
|
||||
"""137-4 — 측점 고름 리스너는 span 끔 · 목록(노면공) 길 · 알약은 기본(여럿) 그대로."""
|
||||
cross = _read("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "if (mark) sectionView.selectMark(mark, false);" in cross
|
||||
assert "sectionView.selectMark(mark, false);" in cross # 152-5 덮는 것 없으면 null = 풂
|
||||
assert "markStructure: (structureId) => sectionView.selectMark(structureId)," in cross
|
||||
types = _read("B06_Section", "B06_Section_UI_Section_View_Types.ts")
|
||||
assert "selectMark: (structureId: string | null, span?: boolean) => void;" in types
|
||||
|
||||
@@ -97,7 +97,7 @@ def test_좌측_관_상자_강조_스크롤() -> None:
|
||||
assert 'body.classList.add("is-active");' in dock
|
||||
assert 'if (scroll) body.scrollIntoView({ block: "nearest" });' in dock
|
||||
# 관 상자 칸을 고치면 관을 고름 — 횡단에서 누른 것과 같은 길
|
||||
page = _src("B06_Section/B06_Section_UI_Page_Structures_Panel.ts")
|
||||
page = _src("B06_Section/B06_Section_UI_Page_Structures_Selection.ts")
|
||||
assert 'if (id === "inlet" || id === "outlet" || id === "pipe") return id;' in page
|
||||
css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css")
|
||||
assert ".b06-chart__culvert-pipe.is-active {" in css
|
||||
|
||||
@@ -152,3 +152,32 @@ def test_blocked_spots_push_farther_with_leader() -> None:
|
||||
|
||||
entities = label_entities("d", placed)
|
||||
assert [e["type"] for e in entities] == ["Text", "Text", "PolyLine"]
|
||||
|
||||
|
||||
def test_ip_text_takes_centerline_color() -> None:
|
||||
"""153-2 — IP 글 · 지시선 = 노선 중심선 색 · 교각선 · 원 · R 글은 조각 색 그대로."""
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import _COLORS, _piece_entities
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import label_entities
|
||||
|
||||
item = {"name": "IP1", "apex": [100.0, 100.0], "start": [90.0, 100.0], "end": [100.0, 110.0]}
|
||||
pieces = {
|
||||
"ip": {"items": [item]},
|
||||
"radius": {"items": [{"label": "R=12", "middle": [100.0, 98.0], "dir": [0.0, -1.0]}]},
|
||||
}
|
||||
drawing = build_plan_kind_drawing("plan_x", "시험", (0.0, 0.0, 300.0, 200.0), pieces)
|
||||
texts = {e["shapeData"]["label"]: e for e in _texts(drawing)}
|
||||
ip_text = texts["IP1"]
|
||||
assert ip_text["lineColor"] == _COLORS["centerline"]
|
||||
assert ip_text["shapeData"]["options"]["textColor"] == _COLORS["centerline"]
|
||||
assert texts["R=12"]["shapeData"]["options"]["textColor"] == _COLORS["radius"]
|
||||
marks = [
|
||||
e for e in drawing["entities"] if e["layerId"] == "b07-plan-ip" and e["type"] == "PolyLine"
|
||||
]
|
||||
assert len(marks) == 2 # 교각선 · 원
|
||||
assert {e["lineColor"] for e in marks} == {_COLORS["ip"]}
|
||||
|
||||
labels: list = []
|
||||
_piece_entities("d", "ip", {"items": [item]}, lambda p: (p[0], p[1]), labels=labels)
|
||||
far = [{**labels[0], "spot": {"x": 0.0, "y": 0.0, "far": True}, "rect": (0, 0, 1, 1)}]
|
||||
leader = label_entities("d", far)[1]
|
||||
assert leader["lineColor"] == _COLORS["centerline"]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""152-5 B06 노면공 강조.
|
||||
|
||||
못박는 것
|
||||
- 포장층마다 범위 표식(종류 · 시작 · 끝) — 고른 노면공의 층만 is-active · 겹친 노면공은 하나만 · 없음 = 모두 풂.
|
||||
- 노면공(G군)은 범위 카드 테두리(--span) 대신 포장층 · 다른 구간형은 --span 그대로.
|
||||
- 카드 누름(③ 덮는 구조물)은 노면공을 안 엶 · 목록 · 알약(① id 길)은 그대로 · 덮는 것 없으면 알약 · 강조 풂.
|
||||
|
||||
Node 헬퍼 `helper_152_5_pavement_pick.cjs` 가 SVG 그릇 흉내로 돌림. 화면은 사용자 확인.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_152_5_pavement_pick.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_포장층_범위_표식(out):
|
||||
assert out["marks"] == [
|
||||
{"paveType": "pavement_concrete", "paveStart": "0", "paveEnd": "40"},
|
||||
{"paveType": "pavement_concrete", "paveStart": "20", "paveEnd": "60"},
|
||||
]
|
||||
|
||||
|
||||
def test_고른_노면공_층만_강조(out):
|
||||
assert out["pickSecond"] == [False, True] # 시작 · 끝 거꾸로 와도 같은 범위
|
||||
assert out["otherType"] == [False, False]
|
||||
assert out["pickFirst"] == [True, False]
|
||||
assert out["cleared"] == [False, False]
|
||||
|
||||
|
||||
def test_카드_누름은_노면공을_안_엶(out):
|
||||
cover = out["cover"]
|
||||
assert cover["default"] == "pave" # 거르개 없으면 옛 길 그대로(B05)
|
||||
assert cover["skipped"] is None
|
||||
assert cover["wallFirst"] == "wall"
|
||||
assert cover["byId"] == "pave" # 목록 · 알약 고름
|
||||
|
||||
|
||||
def test_화면_배선():
|
||||
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
assert '?.group === "G"' in view
|
||||
assert "highlightPavement(card, pave ? mark : undefined);" in view
|
||||
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
|
||||
assert panel.count("resolveSelection(selection, pipeFacilities, structures, coverable)") == 2
|
||||
assert "section.selectAt(selection)" not in panel # 공용 selectAt 은 노면공을 덮어 엶
|
||||
css = _read("B06_Section", "B06_Section_UI_Style_Cross_Areas.css")
|
||||
assert ".b06-chart__pavement.is-active" in css
|
||||
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""153-1 B07 횡단 장 배치 칸.
|
||||
|
||||
못박는 것
|
||||
- 배치 칸 자리 = 좌측 「횡단도」 묶음 맨 아래(한 번만) — 정보 칸을 다시 그려도 안 사라짐.
|
||||
- 공용 입력칸(ui-field__label · ui-input--sm · 1~10) 둘 + 작은 ghost 단추 [이 장] [모든 장].
|
||||
- 장이 아니면 [이 장] 꺼짐 · [모든 장]은 됨 · 범위 밖 값 = 알림만.
|
||||
|
||||
Node 헬퍼 `helper_153_b07_cross_layout.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_153_b07_cross_layout.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_횡단도_묶음_맨_아래(out):
|
||||
assert out["slotLast"] is True
|
||||
assert out["slotCount"] == 1
|
||||
|
||||
|
||||
def test_공용_입력칸과_작은_단추(out):
|
||||
sheet = out["sheet"]
|
||||
assert sheet["boxClass"] == "b07-cross-layout"
|
||||
assert sheet["labels"] == ["배치 열", "행"]
|
||||
assert [i["value"] for i in sheet["inputs"]] == ["3", "4"]
|
||||
for field in sheet["inputs"]:
|
||||
assert field["type"] == "number" and field["min"] == "1" and field["max"] == "10"
|
||||
assert "ui-input--sm" in field["cls"]
|
||||
assert [b["text"] for b in sheet["buttons"]] == ["이 장", "모든 장"]
|
||||
for button in sheet["buttons"]:
|
||||
assert button["cls"] == "ui-btn ui-btn--ghost ui-btn--sm" and not button["disabled"]
|
||||
|
||||
|
||||
def test_이_장_모든_장_누름(out):
|
||||
clicks = out["sheetClicks"]
|
||||
assert clicks["applied"] == [[2, 5]]
|
||||
assert clicks["defaults"] == [[2, 5]]
|
||||
assert [t[0] for t in clicks["toasts"]] == ["error"] # 11 = 범위 밖
|
||||
|
||||
|
||||
def test_장_아니면_이_장_꺼짐(out):
|
||||
not_sheet = out["notSheet"]
|
||||
assert not_sheet["disabled"] == [True, False]
|
||||
assert not_sheet["defaults"] == [[4, 4]]
|
||||
assert not_sheet["values"] == ["4", "4"]
|
||||
@@ -49,7 +49,7 @@ def test_폼을_다시_그리면_강조_다시() -> None:
|
||||
|
||||
def test_상자_칸을_고치면_그_부분을_고름() -> None:
|
||||
"""54-30 — 칸 고침 = 횡단에서 그 벽을 누른 것과 같은 길 · 단 수 칸은 고른 단을 지킴."""
|
||||
page = _src("B06_Section/B06_Section_UI_Page_Structures_Panel.ts")
|
||||
page = _src("B06_Section/B06_Section_UI_Page_Structures_Selection.ts")
|
||||
assert '[data-spec-item="outlet.tiers"]' in page
|
||||
assert "`extra${Number(box.dataset.specIndex)}`" in page
|
||||
assert "refreshCard?.(target.chainage_m);" in page
|
||||
|
||||
@@ -61,6 +61,7 @@ def test_거리_정의는_한_곳뿐이고_옛_0_05는_없다():
|
||||
("design_view_cross.ts", "A00_Common/design"),
|
||||
("B05_Profile_UI_Selection.ts", "B05_Profile"),
|
||||
("B06_Section_UI_Page_Structures_Panel.ts", "B06_Section"),
|
||||
("B06_Section_UI_Page_Structures_Selection.ts", "B06_Section"),
|
||||
("B06_Section_UI_Page_Structure_Pick.ts", "B06_Section"),
|
||||
("B05_Profile_UI_Structure_Pick_Session.ts", "B05_Profile"),
|
||||
):
|
||||
@@ -187,7 +188,9 @@ def test_B05_되살리기_같은_측점_관보다_구조물_id가_먼저(out):
|
||||
|
||||
# ── 배선 ───────────────────────────────────────────────────────────────
|
||||
def test_횡단도_벽_구체_눌러도_폼은_같은_길_하나():
|
||||
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
|
||||
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") + _read(
|
||||
"B06_Section", "B06_Section_UI_Page_Structures_Selection.ts"
|
||||
)
|
||||
assert "showPipeAt" not in panel # 관 전용 길을 없앰
|
||||
assert panel.count("panel.showAtChainage(") == 3 # 기슭막이 · 세월교 · BOX
|
||||
assert panel.count("panel.releasePipe()") == 3 # 벽 선택을 풀 때는 관 폼만 비움
|
||||
|
||||
Reference in New Issue
Block a user