fix(B06·B05): 노면공 고름 세 갈래 · 형상 누름 = 목록 고름 · 카드 하나 = 그 측점(189-1)

- ② 카드 안 노면공 포장층 누름 = 목록 고름과 같은 길(범위 카드 전부 · 포장층 강조) — 카드 고름보다 먼저 가로챔
- ③ 카드 하나 누름 = 연 노면공 · 범위 강조 풂 · 그 카드 하나(165-1 노면공 지킴 · 167-3 재누름 그대로 없앰)
- 카드 고름 세션 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만)
- 종단: 구조물 넘김이면 측점선 · 3D 띠 안 켬
- 새 시험 test_189_1_pave_pick · test_165 · test_188_1 기대값 41번 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 13:21:23 +09:00
co-authored by Claude Opus 5.5
parent e744da8af1
commit 9e69bec512
12 changed files with 301 additions and 72 deletions
@@ -64,26 +64,38 @@ function standardEdges(edges: { left: Edge; right: Edge }, design: CrossDesign)
};
}
type PaveMark = { type_id: string; start_m?: number | null; end_m?: number | null };
/** 포장층 범위 표식(종류 · 시작 · 끝)이 그 노면공인가 — 겹친 노면공 가름. */
function isPaveLayerOf(data: DOMStringMap, mark: PaveMark): boolean {
const [low, high] = [mark.start_m ?? 0, mark.end_m ?? 0].sort((a, b) => a - b);
return (
data.paveType === mark.type_id &&
Math.abs(Number(data.paveStart) - low) < 0.01 &&
Math.abs(Number(data.paveEnd) - high) < 0.01
);
}
/**
* 고른 노면공의 포장층만 `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);
export function highlightPavement(root: ParentNode, mark?: PaveMark): void {
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);
layer.classList.toggle("is-active", !!mark && isPaveLayerOf(layer.dataset, mark));
}
}
/** 누른 자리의 포장층 → 그 노면공(189-1 · 형상 누름 = 목록 고름) · 범위 표식 없는 층 · 밖 = undefined. */
export function pavementAt<T extends PaveMark>(
target: EventTarget | null,
marks: ReadonlyArray<T>,
): T | undefined {
const layer = (target as Element | null)?.closest?.<SVGElement>(".b06-chart__pavement");
if (!layer?.dataset.paveType) return undefined;
return marks.find((mark) => isPaveLayerOf(layer.dataset, mark));
}
/**
* 포장층 — 노면선(= 포장 윗면) 아래로 두께만큼. 노면공 범위(`section.pavement`)면 그 두께 · 무늬 ·
* 확폭 켬/끔(여러 건은 아래로 차례로 쌓음) · 없으면 카드 포장 켬일 때 표준 두께 채움만.
@@ -123,6 +123,8 @@ export interface B06StructuresPanel {
selectionAt: (chainageM: number | null) => DesignSelection;
/** 다음 카드 고름이 측점보다 먼저 열 구조물 — 알약 · 넘김값이 누른 것(null = 측점 규칙대로). */
prefer: (structureId: string | null) => void;
/** 지금 카드 고름이 먼저 열 구조물(목록 · 알약 · 넘김) — 없으면 null(카드만 고름 · 189-1). */
preferred: () => string | null;
/** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */
selectStructure: (structureId: string) => void;
/** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */
@@ -164,8 +166,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
markTypes.find((type) => type.type_id === entry.type_id)?.group !== "G";
/** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */
let preferredId: string | null = null;
/** 폼에 열린 구조물 — 범위 안 빈 카드를 눌러도 연 노면공을 지킴(165-1). */
let openId: string | null = null;
/** 그 구조물로 갈 카드 측점 — 구간형은 범위 안 카드 중 기준점에 가까운 것(경계 기준점이 범위 밖 카드로 안 감 · 165-2). */
const focusOf = (structure: StructureInstance): number => {
const anchor = structureAnchorM(structure);
@@ -209,7 +209,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
}
},
onSelect: (structure) => {
openId = structure?.structure_id ?? null;
if (!structure) {
deps.markStructure?.(null); // 풂 = 알약도 끔(53-2)
return;
@@ -547,24 +546,15 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
prefer: (structureId) => {
preferredId = structureId;
},
preferred: () => preferredId,
selectStructure: (structureId) => section.selectById(structureId),
showAtChainage: (chainageM) => {
currentChainageM = null;
const selection: DesignSelection = { chainageM, structureId: preferredId };
// 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에
// 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳).
// 카드 하나 고름 = 그 측점 기준 — 연 노면공은 풂(189-1 · 노면공은 덮어 열지 않음 152-5)
const hit = resolveSelection(selection, pipeFacilities, structures, coverable);
// 범위 안 빈 카드(관 · 덮는 구조물 없음) = 연 노면공 그대로 · 카드만 옮김(165-1)
const open = structures.find((entry) => entry.structure_id === openId);
if (
chainageM !== null &&
hit.pipeChainageM === null &&
hit.structureId === null &&
open &&
!coverable(open) &&
coversChainage(open, chainageM)
)
return { chainageM, structureId: openId };
// 폼은 위에서 푼 값으로 엶 — 공용 `selectAt` 은 노면공을 덮어 엶(152-5)
if (hit.pipeChainageM !== null) {
currentChainageM = hit.pipeChainageM;
+17 -11
View File
@@ -68,7 +68,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 { highlightPavement, pavementAt } 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";
@@ -270,6 +270,8 @@ export function createSectionView(
/** 카드 선택 변경 수신자(좌측 구조물 폼). 페이지가 등록한다. */
let stationSelectListener: ((stationId: string | null) => void) | null = null;
/** 카드 안 노면공 형상 누름 수신자 — 목록 고름과 같은 길(189-1). */
let pavementSelectListener: ((structureId: string) => void) | null = null;
/**
* 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**.
@@ -288,16 +290,6 @@ export function createSectionView(
/** 같은 측점을 다시 고르면 선택을 푼다(2026-08-02 사용자 지시) — 카드·측점선 어느 쪽이든. */
const selectStation = (stationId: string, scroll: boolean): void => {
// 고른 노면공 범위 안 카드는 다시 눌러도 그대로 — 풂은 빈 자리 · 목록 · 알약(167-3)
const section = currentDetail?.cross_sections.find((entry) => entry.station_id === stationId);
const held =
section && isPavement(spanMarkId)
? inSpan(
section,
spanMarks.find((entry) => entry.structure_id === spanMarkId),
)
: false;
if (selectedStationId === stationId && held) return;
const previous = selectedStationId;
const deselect = selectedStationId === stationId;
selectedStationId = deselect ? null : stationId;
@@ -429,6 +421,17 @@ export function createSectionView(
grid.addEventListener("click", (event) => {
if (!(event.target as HTMLElement).closest(".b06-cross-card")) clearSelection();
});
// 노면공 형상 누름 = 목록 고름(범위 카드 전부 · 형상 강조) — 카드 고름보다 먼저 가로챔(189-1)
grid.addEventListener(
"click",
(event) => {
const pave = pavementAt(event.target, spanMarks);
if (!pave?.structure_id || !isPavement(pave.structure_id)) return;
event.stopPropagation();
pavementSelectListener?.(pave.structure_id);
},
true,
);
// 열 수 = 사용자 값 · 폭 = (칸 폭 − 간격) ÷ 열 수(`_Section_Columns` · 138-3).
cachedCardWidth = crossCardWidth(renderWidth, columnCount, CROSS_GRID_GAP);
// CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제
@@ -609,6 +612,9 @@ export function createSectionView(
setStationSelectListener(listener) {
stationSelectListener = listener;
},
setPavementSelectListener(listener) {
pavementSelectListener = listener;
},
setStructureEdit(edit) {
structureEdit = edit;
},
@@ -56,6 +56,8 @@ export interface SectionViewController {
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
* 올린다(2026-08-29 일원화). null = 선택 해제. */
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
/** 카드 안 노면공 형상 누름(189-1) — 목록 고름과 같은 길로 보냄. */
setPavementSelectListener: (listener: (structureId: string) => void) => void;
clear: () => void;
/** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자
* 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */