feat(b06): 물넘이포장 형상 누름 = 다른 시설과 같은 한 고름 · 보라 손잡이 아래 · 끌기는 고름 아님 (287-5)

- 형상 고름 판정면(B06_Section_UI_Cross_Shape_Pick) — data-structure-id 단 형상마다 · 구조물 판정면 앞(아래)에 꽂아 보라 손잡이 판정이 위(요구 185 ①)
- 누름만 고름 — 3 px 넘게 움직이면 끌기라 고름 아님(②) · 노면공 포장층(범위 표식)은 밖 · 포장 고름과 안 섞임(③)
- 누름 → toggleRevet("pipe") = 세월교 · BOX 와 같은 창구(폼 · 알약 · 측점선) · 물넘이 범위 옆 카드도 그 물넘이 자리로
- 목록 · 알약 · 폼 고름 → 카드 형상 강조(highlightShapes) · 물넘이 포장층 표식 = 관 지점 알약 id
- 시험 test_287(판정면 차례 · 누름 · 끌기 · 강조)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-12 21:07:53 +09:00
co-authored by Claude Opus 5.5
parent 2b9440a44e
commit 75050815fa
8 changed files with 240 additions and 2 deletions
@@ -111,7 +111,12 @@ export function appendFordPavementOverlay(
[...svg.children]
.slice(from)
.filter((el) => el.getAttribute("class") === "b06-chart__pavement")
.forEach((el) => el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement"));
.forEach((el) => {
el.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement");
// 형상 고름 표식(287-5) — 관 지점 알약 id 와 같은 글
if (typeof spec.chainage_m === "number")
el.setAttribute("data-structure-id", `pipe-${spec.chainage_m.toFixed(2)}`);
});
// 월류 높이선 — 콘크리트 윗면(설계 노면) 위로 월류 높이만큼 · 같은 각도(그림_183)
const depth = spec.depth_m ?? 0;
if (depth > 0)
@@ -0,0 +1,60 @@
/* =============================================================================
* B06_Section_UI_Cross_Shape_Pick.ts
* 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2 · 요구 185).
*
* 그린 형상(`data-structure-id` 단 포장층 · 돌 무늬 둘레)마다 투명 판정면 — 누르면 그 구조물을 고름
* (다른 시설 형상 고름과 같은 창구 `onPick`). 지킬 것:
* ① 보라 손잡이(설계선 판정 · 뒤에 붙음)가 위 — 판정면은 구조물 판정면 바로 앞(아래)에 꽂고
* 구조물 판정 클래스(`b06-chart__culvert-revet-hit`)를 안 씀(보라 판정이 그 앞에 꽂혀 형상 위로 옴).
* ② 누름만 고름 — 눌렀다 3 px 넘게 움직이면(끌기) 고름 아님.
* ③ 노면공 포장 고름과 안 섞임 — 노면공 범위 표식(`data-pave-type`) 없는 형상만 · 누름은 번지지 않음.
* ========================================================================== */
const SVG_NS = "http://www.w3.org/2000/svg";
/** 끌기로 볼 움직임(px) — 이보다 크면 고름 아님. */
export const SHAPE_DRAG_PX = 3;
/** 판정면을 꽂고 강조 setter 를 돌려줌 — 형상이 없으면 null. */
export function attachShapePick(
layer: SVGElement,
onPick?: (structureId: string) => void,
): ((active: boolean) => void) | null {
const shapes = [...layer.querySelectorAll<SVGElement>("[data-structure-id]")].filter(
(el) => !el.hasAttribute("data-pave-type"),
);
if (!shapes.length) return null;
const kids = [...layer.children];
const firstStructureHit = kids.find((el) =>
el.classList.contains("b06-chart__culvert-revet-hit"),
);
for (const shape of onPick ? shapes : []) {
const id = shape.getAttribute("data-structure-id") ?? "";
const hit = document.createElementNS(SVG_NS, "polygon");
hit.setAttribute("points", shape.getAttribute("points") ?? "");
hit.setAttribute("class", "b06-chart__shape-hit");
let down: { x: number; y: number } | null = null;
hit.addEventListener("pointerdown", (event) => {
down = { x: event.clientX, y: event.clientY };
});
hit.addEventListener("click", (event) => {
const moved =
down && Math.hypot(event.clientX - down.x, event.clientY - down.y) > SHAPE_DRAG_PX;
down = null;
if (moved) return; // 끌기 = 고름 아님(②)
event.stopPropagation();
onPick?.(id);
});
layer.insertBefore(hit, firstStructureHit ?? null);
}
return (active) => shapes.forEach((shape) => shape.classList.toggle("is-active", active));
}
/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴). */
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,
);
}
}
+11
View File
@@ -20,6 +20,7 @@ import {
appendFordSurfaceDropPlan,
} from "./B06_Section_UI_Cross_Ford_Pavement";
import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { attachShapePick } from "./B06_Section_UI_Cross_Shape_Pick";
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch";
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
@@ -574,6 +575,16 @@ export function createCrossSectionCard(
};
setChuteActive(highlightRevet === "chute");
}
// 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2) — 다른 시설과 같은 창구 · 보라 손잡이 아래 · 누름만
const setShapeActive = attachShapePick(plotLayer, revetOffset && (() => toggleRevet("pipe")));
if (setShapeActive) {
const baseSetActive = setRevetActive;
setRevetActive = (key) => {
baseSetActive(key);
setShapeActive(key === "pipe");
};
setShapeActive(highlightRevet === "pipe");
}
// 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2).
const lineDesign = section.design;
linePick = attachDesignLinePick({
@@ -49,7 +49,13 @@ export function wireStructureSelection(
const sectionAt = detail()?.cross_sections.find(
(entry) => Math.abs(entry.chainage_m - chainageM) < 0.01,
);
return (sectionAt && stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m) ?? chainageM;
// 물넘이포장 범위 옆 카드 = 그 물넘이 자리(287-5 · 형상 누름이 그 시설을 엶)
return (
(sectionAt &&
(stationControls.structureSpan.ownerOf(sectionAt)?.chainage_m ??
sectionAt.ford_pavement?.chainage_m)) ??
chainageM
);
};
// 유입구 "구조" ↔ B06 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에
@@ -69,6 +69,7 @@ import {
import type { SectionViewController } from "./B06_Section_UI_Section_View_Types";
import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice";
import { highlightPavement, pavementAt } from "./B06_Section_UI_Cross_Design_Surface";
import { highlightShapes } from "./B06_Section_UI_Cross_Shape_Pick";
import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile";
import type { DesignPageShared } from "../A00_Common/design/design_shared";
@@ -243,6 +244,8 @@ export function createSectionView(
let spanMarks: ReadonlyArray<StructureInstance> = [];
let spanTypes: ReadonlyArray<StructureType> = [];
let spanMarkId: string | null = null;
/** 고른 구조물(알약 · 목록 · 폼) — 물넘이포장 · 맹암거 형상 강조(287-5 · 288-2). */
let pickedMarkId: string | null = 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;
@@ -261,6 +264,7 @@ export function createSectionView(
const pave = span && isPavement(spanMarkId);
card.classList.toggle("b06-cross-card--span", span);
highlightPavement(card, pave ? mark : undefined);
highlightShapes(card, pickedMarkId);
};
const syncSpanHighlights = (): void => {
for (const section of currentDetail?.cross_sections ?? []) {
@@ -606,6 +610,7 @@ export function createSectionView(
selectMark: (structureId, span = true) => {
// span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 포장층은 테두리가 아니라 늘 켬(165-2)
spanMarkId = span || isPavement(structureId) ? structureId : null;
pickedMarkId = structureId;
syncSpanHighlights();
profile.selectMark(structureId);
},
@@ -324,6 +324,13 @@
cursor: pointer;
}
/* 물넘이포장 · 맹암거 형상 고름 판정면(287-5 · 288-2) — 보라 손잡이 아래 */
.b06-chart__shape-hit {
fill: transparent;
stroke: none;
cursor: pointer;
}
/* BOX암거 끝 선택 강조 — 고른 절반을 옅게 칠하고 테두리를 두른다
(2026-08-30 사용자: 선택해도 하이라이트가 없었다). 색은 기슭막이·집수정 강조와
같은 계열이되, 면이 넓어 채도를 낮춘다. */
@@ -51,9 +51,35 @@ class Node {
getAttribute(k) {
return this.attrs[k] ?? null;
}
hasAttribute(k) {
return k in this.attrs;
}
append(...kids) {
this.children.push(...kids);
}
insertBefore(kid, ref) {
const at = ref ? this.children.indexOf(ref) : -1;
if (at < 0) this.children.push(kid);
else this.children.splice(at, 0, kid);
}
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(" ");
},
};
}
addEventListener(type, fn) {
(this.listeners ??= {})[type] = fn;
}
querySelectorAll(selector) {
const attr = /^\[([\w-]+)\]$/.exec(selector)?.[1];
const all = this.children.flatMap((k) => [k, ...k.querySelectorAll(selector)]);
return all.filter((k) => attr && k.hasAttribute(attr));
}
}
globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) };
const pave = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Pavement.ts"));
@@ -90,7 +116,81 @@ const drew = pave.appendFordPavementOverlay(
);
const ofClass = (c) => svg.children.filter((k) => (k.attrs.class ?? "").split(" ").includes(c));
// 287-5 형상 고름 — 판정면 차례 · 누름 = 고름 · 끌기 = 고름 아님 · 노면공 포장층 안 섞임 · 강조
const shapePick = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts"));
const layer = new Node("g");
const fordShape = new Node("polygon");
fordShape.setAttribute("class", "b06-chart__pavement b06-chart__ford-pavement");
fordShape.setAttribute("data-structure-id", "pipe-230.00");
fordShape.setAttribute("points", "0,0 1,0 1,1");
const paveShape = new Node("polygon"); // 노면공 포장층 — 범위 표식이 있어 형상 고름 밖
paveShape.setAttribute("data-structure-id", "pave-1");
paveShape.setAttribute("data-pave-type", "pavement_concrete");
const structureHit = new Node("polygon");
structureHit.setAttribute("class", "b06-chart__culvert-revet-hit");
layer.append(fordShape, paveShape, structureHit);
const picked = [];
const setShape = shapePick.attachShapePick(layer, (id) => picked.push(id));
// 보라 손잡이 판정은 뒤에 붙음 — 설계선 고름과 같은 자리(구조물 판정면 앞)에 꽂음
const purple = new Node("polyline");
purple.setAttribute("class", "b06-chart__design-hit");
layer.insertBefore(
purple,
layer.children.find((k) => k.classList.contains("b06-chart__culvert-revet-hit")),
);
const shapeHit = layer.children.find((k) => k.classList.contains("b06-chart__shape-hit"));
const press = (dx) => {
const ev = {
clientX: 10,
clientY: 10,
stopped: false,
stopPropagation() {
this.stopped = true;
},
};
shapeHit.listeners.pointerdown(ev);
const up = {
...ev,
clientX: 10 + dx,
stopPropagation() {
up.stopped = true;
},
};
shapeHit.listeners.click(up);
return up.stopped;
};
const clickStops = press(0);
const dragStops = press(12);
setShape(true);
const activeAfterSet = fordShape.classList.contains("is-active");
shapePick.highlightShapes(layer, "other");
const activeAfterOther = fordShape.classList.contains("is-active");
shapePick.highlightShapes(layer, "pipe-230.00");
const activeAfterSame = fordShape.classList.contains("is-active");
// 물넘이 포장층 표식 = 관 지점 알약 id
const tagged = new Node("g");
pave.appendFordPavementOverlay(
tagged,
{ chainage_m: 230, span_m: 8, depth_m: 0.4, slope_pct: 5 },
tilted,
(o) => o,
(e) => e,
);
const out = {
shapeOrder: layer.children.map((k) =>
(k.attrs.class ?? k.attrs["data-structure-id"]).split(" ").pop(),
),
picked,
clickStops,
dragStops,
activeAfterSet,
activeAfterOther,
activeAfterSame,
pavedShapeIsPicked: picked.includes("pave-1"),
fordTag: tagged.children
.filter((k) => (k.attrs.class ?? "").includes("ford-pavement"))
.map((k) => k.attrs["data-structure-id"]),
drew,
pavePoints: ofClass("b06-chart__ford-pavement").map((k) => k.attrs.points),
planPoints: ofClass("b06-chart__ford-deck-plan").map((k) => k.attrs.points),
@@ -4,6 +4,9 @@
- 287-1 도수로/돌붙임 길이 ↔ 면적 — 길이 넣음 = 월류 폭 × 길이 · 비움 = 칸 기본값 · 월류 폭을 바꾸면
길이가 있을 때만 다시 셈 · 폭이 비면 물넘이 기본 폭.
- 287-3 월류 폭 → 높이 자동 = 세월교 한 벌(`fordHeightM` · 폼 `overflow(…, "slope")`) · 유량 없으면 손 입력.
- 287-5 물넘이포장 형상 누름 = 다른 시설과 같은 고름 창구(`toggleRevet("pipe")` → 폼 · 알약 · 측점선) ·
판정면은 보라 손잡이 아래(요구 185 ①) · 끌기(3 px 넘게 움직임) = 고름 아님(②) · 노면공 포장층
(범위 표식)은 형상 고름 밖(③) · 목록 · 알약 고름이 형상 강조를 따라옴.
- 287-2 · 287-4 콘크리트 윗면 = 설계 노면(서버 · TS 짝이 경사각도로 지대 낮은 쪽 한 경사 · 중심점이 그 위)
· 포장층 = 노면공 포장 형상 노견까지(노면공 항목과 안 섞임) · 월류 높이 점선 = 노면 위 같은 각도 ·
3D 노면을 파지 않음 · 설계 측구 없음.
@@ -91,3 +94,44 @@ def test_월류_폭_넣으면_높이_자동_세월교_한_벌(ts):
form = _read("B05_Profile", "B05_Profile_UI_Drainage_Facility.ts")
assert 'const paveFlowBox = overflow(() => paveForm, "slope");' in form
assert 'if (facility === "ford_pavement") paveFlowBox.fill();' in form
def test_형상_판정면은_보라_손잡이_아래(ts):
order = ts["shapeOrder"]
# 그림 → 형상 판정면 → 보라 손잡이 판정 → 구조물 판정면(위가 이김)
assert (
order.index("b06-chart__shape-hit")
< order.index("b06-chart__design-hit")
< order.index("b06-chart__culvert-revet-hit")
)
def test_형상_누름은_고름_끌기는_고름_아님(ts):
assert ts["picked"] == ["pipe-230.00"] # 누름 한 번만 고름 · 끌기는 안 셈
assert ts["clickStops"] is True and ts["dragStops"] is False # 끌기는 카드로 그대로
assert ts["pavedShapeIsPicked"] is False # 노면공 포장층은 형상 고름 밖(포장 고름 몫)
def test_형상_강조와_표식(ts):
assert ts["activeAfterSet"] is True
assert ts["activeAfterOther"] is False and ts["activeAfterSame"] is True
assert ts["fordTag"] == ["pipe-230.00"] # 포장층 표식 = 관 지점 알약 id
def test_보라_손잡이는_계획고만_형상_누름은_고름만():
view = _read("B06_Section", "B06_Section_UI_Cross_View.ts")
# 형상 누름 = 시설 고름 창구만(설계 · 계획고를 안 건드림) · 보라 선 고름보다 먼저 꽂음(손잡이가 위)
assert 'attachShapePick(plotLayer, revetOffset && (() => toggleRevet("pipe")))' in view
assert view.index("attachShapePick(plotLayer") < view.index("attachDesignLinePick({")
pick = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts")
assert "onDesignChange" not in pick and "design_elevation" not in pick
assert '"b06-chart__culvert-revet-hit"' in pick # 구조물 판정면 앞(아래)에 꽂을 기준만
assert 'hit.setAttribute("class", "b06-chart__shape-hit")' in pick
# 보라 손잡이 판정 = 계획고 · 사면 이동 창구(설계선 고름 · 구조물 판정면 아래)
design_pick = _read("B06_Section", "B06_Section_UI_Cross_Design_Pick.ts")
assert 'el.classList.contains("b06-chart__culvert-revet-hit")' in design_pick
# 목록 · 알약 · 폼 고름 → 형상 강조
section_view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
assert "highlightShapes(card, pickedMarkId);" in section_view
selection = _read("B06_Section", "B06_Section_UI_Page_Structures_Selection.ts")
assert "sectionAt.ford_pavement?.chainage_m" in selection