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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user