diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 2d8958dd7..308d96a8c 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -419,7 +419,7 @@ export function attachDesignLinePick(input: { const pieces = visiblePieces(mergeStraightPieces(runs), occluders); if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces)); - // 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김. + // 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1). const firstStructureHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"), ); diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index f6f8f101e..fcd7889fe 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -236,6 +236,8 @@ const inside = (p: Pt, ring: Pt[]): boolean => * 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이. * 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다 * 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역. + * 고르기 차례(295-1 · 카드와 같은 잣대) = 선 > 작은 면 > 큰 면 · 띠 — `bandArea`(그 자리 면적 띠 + * 넓이 · 그림 px²)가 영역보다 작을 때만 띠(표가 맡음). */ export function pickAt( point: Pt, @@ -244,7 +246,8 @@ export function pickAt( rings: Pt[][], areas: number[], pieces: SplitPiece[], -): { piece: number } | { face: number } | null { + bandArea: number | null = null, +): { piece: number } | { face: number } | { band: true } | null { const gaps = paths.map((path) => pathGap(point, path) * scale); const nearest = gaps.reduce((best, gap, i) => (gap < gaps[best] ? i : best), 0); const ends = (piece: SplitPiece): Pt[] => [ @@ -268,9 +271,17 @@ export function pickAt( (face === undefined || gaps[nearest] * 2 <= across) ) return { piece: nearest }; + const faceArea = face === undefined ? Infinity : Math.abs(shoelace(rings[face])); + if (bandArea !== null && bandArea < faceArea) return { band: true }; return face === undefined ? null : { face }; } +const shoelace = (ring: Pt[]): number => + ring.reduce((sum, [x0, y0], i) => { + const [x1, y1] = ring[(i + 1) % ring.length]; + return sum + (x0 * y1 - x1 * y0) / 2; + }, 0); + /** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */ const pieceKey = (piece: SplitPiece | undefined): string => piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : ""; @@ -283,8 +294,8 @@ export function attachDetailSelect(input: { picked: (additive: boolean) => void; /** [칸에 넣기] 단추. */ commit: () => void; - /** 면적 고르기 중 그 자리에 면적 띠가 있나 — 있으면 영역 대신 띠(표가 맡음). */ - band?: (event: MouseEvent) => boolean; + /** 면적 고르기 중 그 자리 면적 띠 넓이(그림 px²) · 없음 = null — 영역보다 작을 때만 띠(295-1). */ + band?: (event: MouseEvent) => number | null; }): DetailSelect { const { cad } = input; let pieces: SplitPiece[] = []; @@ -343,7 +354,8 @@ export function attachDetailSelect(input: { if (!ctm || !layer?.ownerSVGElement?.contains(event.target as Node)) return null; const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(ctm.inverse()); const areas = faces.map((face) => face.area); - return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces); + const band = mode === "area" ? (input.band?.(event) ?? null) : null; + return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces, band); }; const onClick = (event: MouseEvent): void => { @@ -352,9 +364,10 @@ export function attachDetailSelect(input: { const additive = shift || mode === "area"; noteText = null; if (!pick || !("piece" in pick)) { - if (pick && !(mode === "area" && input.band?.(event))) { - // 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) - event.stopPropagation(); + if (pick && "face" in pick) { + // 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) · + // 띠보다 좁은 영역이라 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬(295-1) + event.stopImmediatePropagation(); const index = pick.face; chosenFaces = toggleSelection(chosenFaces, index, shift); selected = regionEdges(chosenFaces.map((i) => faces[i])); diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts index 366264f28..690908d7c 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -25,6 +25,7 @@ import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom"; import { attachDetailSelect } from "./B06_Section_UI_Cross_Detail_Select"; +import { pointsArea } from "./B06_Section_UI_Cross_Pick_Order"; /** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ export const MAX_ROWS = 12; @@ -387,13 +388,16 @@ export function attachDetailTable(host: DetailTableHost): { if (picking?.kind === "length" && !additive) commitSelection(); }, commit: commitSelection, - band: (event) => !!bandAt(event), + band: (event) => { + const hit = bandAt(event); + return hit ? pointsArea(hit.getAttribute("points")) : null; + }, }); const bandAt = (event: MouseEvent): Element | undefined => document .elementsFromPoint(event.clientX, event.clientY) .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); - // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 누름은 위가 막음(261-3 거리 판정) + // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4) — 선 · 띠보다 좁은 영역은 위가 막음(261-3 거리 판정 · 295-1) const onPick = (event: MouseEvent): void => { if (picking?.kind !== "area") return; const hit = bandAt(event); diff --git a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts new file mode 100644 index 000000000..e3fd89830 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts @@ -0,0 +1,58 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Pick_Order.ts + * 횡단 카드 고르기 차례(295-1) — 고를 자리가 좁은 것이 위(모달 `pickAt` 과 같은 잣대). + * + * 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) · + * 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로. + * 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님. + * ========================================================================== */ + +/** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */ +const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"]; +/** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */ +const LINE_HIT_CLASS = "b06-chart__design-hit"; +/** 끌기로 볼 움직임(px) — 형상 고름(`SHAPE_DRAG_PX`)과 같은 값. */ +const DRAG_PX = 3; + +/** `points` 속성 다각형 넓이(px²) — 점이 셋 못 되면 0. */ +export function pointsArea(points: string | null): number { + const pts = (points ?? "") + .trim() + .split(/\s+/) + .map((pair) => pair.split(",").map(Number)) + .filter((p) => p.length === 2 && p.every(Number.isFinite)); + if (pts.length < 3) return 0; + let twice = 0; + pts.forEach(([x0, y0], i) => { + const [x1, y1] = pts[(i + 1) % pts.length]; + twice += x0 * y1 - x1 * y0; + }); + return Math.abs(twice) / 2; +} + +/** 판정면을 넓이 차례로 다시 세우고 보라 선 판정을 맨 위로 · 끌기 누름을 막음. */ +export function stackPickTargets(layer: SVGElement): void { + const kids = [...layer.children]; + const surfaces = kids + .filter((el) => SURFACE_HIT_CLASSES.some((name) => el.classList.contains(name))) + .map((el) => ({ el, area: pointsArea(el.getAttribute("points")) })) + .sort((a, b) => b.area - a.area) + .map(({ el }) => el); + const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS)); + layer.append(...surfaces, ...lines); + let down: { x: number; y: number } | null = null; + layer.addEventListener( + "pointerdown", + (event) => (down = { x: (event as PointerEvent).clientX, y: (event as PointerEvent).clientY }), + true, + ); + layer.addEventListener( + "click", + (event) => { + const { clientX, clientY } = event as MouseEvent; + if (down && Math.hypot(clientX - down.x, clientY - down.y) > DRAG_PX) event.stopPropagation(); + down = null; + }, + true, + ); +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 7f797e240..fbaf418af 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -21,6 +21,7 @@ import { } 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 { stackPickTargets } from "./B06_Section_UI_Cross_Pick_Order"; import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain"; import { raiseWalls } from "./B06_Section_UI_Cross_Wall"; import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch"; @@ -604,6 +605,7 @@ export function createCrossSectionCard( }); setLineActive = linePick.setActive; setLineActive(pickedLineAt(section.station_id)); + stackPickTargets(plotLayer); // 고르기 차례 = 좁은 것 위 · 보라 선 맨 위(295-1) } // 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한).