feat(B06): 노면공 고름 = 범위 카드 테두리 대신 포장층 강조 · 카드 누름으로 노면공 열기 끊음(152-5)

- 포장층 범위 표식(종류 · 시작 · 끝) · 고른 노면공 층만 is-active(겹침은 하나만)
- 다른 구간형은 --span 그대로
- resolveSelection ③ 덮는 구조물에 거르개 · B06 은 G군 뺌 · 덮는 것 없으면 알약 · 강조 풂

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
This commit is contained in:
2026-10-09 19:13:08 +09:00
co-authored by Claude Opus 5.5
parent 9904d797bb
commit 4ae7ee25cc
9 changed files with 252 additions and 20 deletions
+5 -3
View File
@@ -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 };
}
+1 -1
View File
@@ -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) {
@@ -159,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;
/** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */
@@ -516,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;
@@ -527,13 +533,15 @@ 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 };
},
};
+21 -9
View File
@@ -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);
@@ -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));
@@ -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
@@ -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