diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index ea30b9e4d..38c948fb4 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -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) diff --git a/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts new file mode 100644 index 000000000..17e960758 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Shape_Pick.ts @@ -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("[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("[data-structure-id]")) { + shape.classList.toggle( + "is-active", + !!structureId && shape.getAttribute("data-structure-id") === structureId, + ); + } +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index da538e6af..e8178a2a2 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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({ diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index fc8ef4475..20771b942 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -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 유입측 형식 — 폼에서 고르면 조정값에 반영하고, 시설을 폼에 diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index dc327f46f..f6efcaad2 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -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 = []; let spanTypes: ReadonlyArray = []; 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); }, diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index c24ef3860..b970a0463 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -324,6 +324,13 @@ cursor: pointer; } +/* 물넘이포장 · 맹암거 형상 고름 판정면(287-5 · 288-2) — 보라 손잡이 아래 */ +.b06-chart__shape-hit { + fill: transparent; + stroke: none; + cursor: pointer; +} + /* BOX암거 끝 선택 강조 — 고른 절반을 옅게 칠하고 테두리를 두른다 (2026-08-30 사용자: 선택해도 하이라이트가 없었다). 색은 기슭막이·집수정 강조와 같은 계열이되, 면이 넓어 채도를 낮춘다. */ diff --git a/resources/tester/helper_287_ford_pavement.cjs b/resources/tester/helper_287_ford_pavement.cjs index 124043eb7..9f2a35521 100644 --- a/resources/tester/helper_287_ford_pavement.cjs +++ b/resources/tester/helper_287_ford_pavement.cjs @@ -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), diff --git a/resources/tester/test_287_ford_pavement_followup.py b/resources/tester/test_287_ford_pavement_followup.py index 18af864ed..67a4a1f7f 100644 --- a/resources/tester/test_287_ford_pavement_followup.py +++ b/resources/tester/test_287_ford_pavement_followup.py @@ -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