fix(b06): 고름 강조 정리 — 측점 길은 형상 안 켬 · 연동 관 콘크리트만 · 측점 없는 구조물 스크롤 없음 (307)

- 노면공 포장층 강조 = 포장층을 직접 누른 턴만(paveShapeId) · 알약 · 목록 · 측점선 고름은 범위 테두리만
- 물넘이 콘크리트(pipe-…)는 측점 길 강조(highlightShapes)에서 뺌 — 형상 누름 「관」 고름만 켬 · 맹암거 그대로
- 벽 고름 「관」에 콘크리트 표식(형상 누름 ownerM · 관 없는 카드) · 카드 「관」 setter 를 관 그림 / 콘크리트로 가름(splitPipePick)
- 연동 관 카드 콘크리트 누름 = 관 형상 안 켬 · 콘크리트 ↔ 관 바꿔 누르면 풀지 않고 옮김 · 좌측 하위 컨테이너 켬 그대로
- 측점 없는 구조물 = 카드 고름 · 스크롤 없음(revealStation 스크롤 걷음 · 넘김 focusPicked)
- 시험: test_307_pick_highlight 새로 · 118 · 177_4 · 287 · 294_6 · 294_7 기대값 307 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-13 18:18:20 +09:00
co-authored by Claude Opus 5.5
parent c06ad0835f
commit d3d038349b
17 changed files with 390 additions and 47 deletions
+5 -10
View File
@@ -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),
@@ -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;
/** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */
+21 -5
View File
@@ -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);
}
}
+7 -8
View File
@@ -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;
@@ -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) {
+18 -7
View File
@@ -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). */
@@ -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,
}),
);
+4 -1
View File
@@ -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
@@ -115,7 +115,8 @@ def test_형상_누름은_고름_끌기는_고름_아님(ts):
def test_형상_강조와_표식(ts):
assert ts["activeAfterSet"] is True
assert ts["activeAfterOther"] is False and ts["activeAfterSame"] is True
# 물넘이 콘크리트는 「관」 고름(형상 누름) 몫 — 측점 길 강조(highlightShapes)가 끄지도 켜지도 않음(307)
assert ts["activeAfterOther"] is True and ts["activeAfterSame"] is True
assert ts["fordTag"] == ["pipe-230.00"] # 포장층 표식 = 관 지점 알약 id
@@ -60,7 +60,8 @@ def test_주인_카드_옛_꼴(out):
def test_고름은_주인_구조물(out):
for card in (out["neighbor"], out["owner"]):
assert card["pick"] == ["pipe-90.00"]
assert "is-active" in card["classes"][0].split()
# 측점 길 고름(알약 · 목록 id)은 콘크리트를 안 켬 — 형상 누름 「관」 고름만(307)
assert "is-active" not in card["classes"][0].split()
def test_CSS_연결_꼴_강조가_먼저():
@@ -79,7 +79,8 @@ def test_배선_경사각도_상자():
view = _read("B06_Section_UI_Cross_View.ts")
assert 'shapePickRoute(svg, (at) => toggleRevet("pipe", at))' in view
assert "revetOffset?.select(section.chainage_m, activeRevet, ownerM);" in view
assert 'setShapeActive(key === "pipe");' in view
# 「관」 = 관 그림 · 콘크리트 하나만(307)
assert "splitPipePick(setRevetActive, setShapeActive, shapePicked)" in view
assert len(view.splitlines()) <= 700
shape = _read("B06_Section_UI_Cross_Shape_Pick.ts")
assert 'thickenDown(shape.getAttribute("points") ?? "")' in shape
+165
View File
@@ -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