fix(b06): 물넘이 콘크리트 누름 — 얇은 형상 판정면 아래로 최소 두께 12 px(보라 띠 밖) · 누름 = 그 물넘이 고름(걸친 연동 관 · 자기 다른 관 아님) · 경사각도 상자 (294-7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
@@ -41,7 +41,8 @@ export interface RevetOffsetControl {
|
||||
* (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다).
|
||||
* 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */
|
||||
highlightFor: (section: CrossSection) => RevetPick | null;
|
||||
select: (chainageM: number, key: RevetPick | null) => void;
|
||||
/** `ownerM` = 폼에 올릴 시설 자리를 누른 쪽이 앎(물넘이 콘크리트 · 294-7) — 없으면 카드로 셈. */
|
||||
select: (chainageM: number, key: RevetPick | null, ownerM?: number) => void;
|
||||
/** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */
|
||||
onSelectionChange?: () => void;
|
||||
/** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* 구조물 판정 클래스(`b06-chart__culvert-revet-hit`)를 안 씀(보라 판정이 그 앞에 꽂혀 형상 위로 옴).
|
||||
* ② 누름만 고름 — 눌렀다 3 px 넘게 움직이면(끌기) 고름 아님.
|
||||
* ③ 노면공 포장 고름과 안 섞임 — 노면공 범위 표식(`data-pave-type`) 없는 형상만 · 누름은 번지지 않음.
|
||||
* ④ 얇은 형상(물넘이 콘크리트 · 기본 배율 4 px)은 판정면을 아래쪽으로 `SHAPE_MIN_PX` 두께까지 늘림 —
|
||||
* 노면선 보라 판정 띠(굵기 10 · 위아래 5) 바깥 아래를 누르면 형상(294-7).
|
||||
* ========================================================================== */
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
@@ -15,6 +17,46 @@ const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
export const SHAPE_PICK_EVENT = "b06-structure-shape-pick";
|
||||
/** 끌기로 볼 움직임(px) — 이보다 크면 고름 아님. */
|
||||
export const SHAPE_DRAG_PX = 3;
|
||||
/** 형상 판정면 최소 두께(그림 px) — 보라 판정 띠 아래 반(5)보다 넉넉히(294-7). */
|
||||
export const SHAPE_MIN_PX = 12;
|
||||
|
||||
type XY = [number, number];
|
||||
const parse = (points: string): XY[] =>
|
||||
points
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => pair.split(",").map(Number) as XY)
|
||||
.filter((p) => p.length === 2 && p.every(Number.isFinite));
|
||||
const insideOf = (ring: XY[], [x, y]: XY): boolean =>
|
||||
ring.reduce((inn, [ax, ay], i) => {
|
||||
const [bx, by] = ring[(i + 1) % ring.length];
|
||||
return ay > y !== by > y && x < ax + ((y - ay) * (bx - ax)) / (by - ay) ? !inn : inn;
|
||||
}, false);
|
||||
|
||||
/** 얇은 형상 → 판정면 꼭짓점 — 바로 아래가 형상 밖인 꼭짓점(아랫변)만 내려 두께를 `minPx` 로.
|
||||
* 두께 = 넓이 ÷ 가로 폭(기운 노면도 세로 두께로 잼). */
|
||||
export function thickenDown(points: string, minPx = SHAPE_MIN_PX): string {
|
||||
const ring = parse(points);
|
||||
if (ring.length < 3) return points;
|
||||
const xs = ring.map(([x]) => x);
|
||||
const area = Math.abs(
|
||||
ring.reduce((sum, [x0, y0], i) => {
|
||||
const [x1, y1] = ring[(i + 1) % ring.length];
|
||||
return sum + x0 * y1 - x1 * y0;
|
||||
}, 0) / 2,
|
||||
);
|
||||
const drop = minPx - area / Math.max(Math.max(...xs) - Math.min(...xs), 1e-9);
|
||||
if (drop <= 0) return points;
|
||||
const midX = ring.reduce((sum, [x]) => sum + x, 0) / ring.length;
|
||||
const e = 1e-3;
|
||||
return ring
|
||||
.map(([x, y]): XY => {
|
||||
const probe: XY = [x + (midX > x ? e : midX < x ? -e : 0), y + e];
|
||||
return insideOf(ring, probe) ? [x, y] : [x, y + drop];
|
||||
})
|
||||
.map((p) => p.join(","))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** 판정면을 꽂고 강조 setter 를 돌려줌 — 형상이 없으면 null. */
|
||||
export function attachShapePick(
|
||||
@@ -32,7 +74,7 @@ export function attachShapePick(
|
||||
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("points", thickenDown(shape.getAttribute("points") ?? ""));
|
||||
hit.setAttribute("class", "b06-chart__shape-hit");
|
||||
let down: { x: number; y: number } | null = null;
|
||||
hit.addEventListener("pointerdown", (event) => {
|
||||
@@ -51,11 +93,14 @@ export function attachShapePick(
|
||||
return (active) => shapes.forEach((shape) => shape.classList.toggle("is-active", active));
|
||||
}
|
||||
|
||||
/** 형상 누름 창구(287-5 · 288-2) — 관 지점(`pipe-…` · 물넘이포장) = 그 카드 시설 고름 · 그 밖(맹암거) = id 를
|
||||
* 실은 알림을 위로 띄움(구조물 목록이 받음). */
|
||||
export function shapePickRoute(from: Element, pickPipe: () => void): (structureId: string) => void {
|
||||
/** 형상 누름 창구(287-5 · 288-2) — 관 지점(`pipe-…` · 물넘이포장) = 그 물넘이 누가거리로 고름(카드의 다른 관 ·
|
||||
* 연동 관 아님 · 294-7) · 그 밖(맹암거) = id 를 실은 알림을 위로 띄움(구조물 목록이 받음). */
|
||||
export function shapePickRoute(
|
||||
from: Element,
|
||||
pickPipe: (chainageM: number) => void,
|
||||
): (structureId: string) => void {
|
||||
return (structureId) => {
|
||||
if (structureId.startsWith("pipe-")) return pickPipe();
|
||||
if (structureId.startsWith("pipe-")) return pickPipe(Number(structureId.slice(5)));
|
||||
from.dispatchEvent(new CustomEvent(SHAPE_PICK_EVENT, { bubbles: true, detail: structureId }));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,13 +232,14 @@ export function createCrossSectionCard(
|
||||
},
|
||||
selectCard: () => onSelect(section.station_id),
|
||||
});
|
||||
const toggleRevet = (key: RevetPick): void => {
|
||||
const toggleRevet = (key: RevetPick, ownerM?: number): void => {
|
||||
const wasSelected = isSelected;
|
||||
activeRevet = activeRevet === key ? null : key;
|
||||
showRevetControl(activeRevet !== null);
|
||||
// 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를
|
||||
// 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다.
|
||||
revetOffset?.select(section.chainage_m, activeRevet);
|
||||
// `ownerM` = 폼에 올릴 시설 자리(물넘이 콘크리트 = 그 물넘이 · 294-7)
|
||||
revetOffset?.select(section.chainage_m, activeRevet, ownerM);
|
||||
card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? activeRevet);
|
||||
if (activeRevet !== null && activeArea !== null) {
|
||||
activeArea = null;
|
||||
@@ -587,7 +588,7 @@ export function createCrossSectionCard(
|
||||
// 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2) — 다른 시설과 같은 창구 · 보라 손잡이 아래 · 누름만
|
||||
const setShapeActive = attachShapePick(
|
||||
plotLayer,
|
||||
revetOffset && shapePickRoute(svg, () => toggleRevet("pipe")),
|
||||
revetOffset && shapePickRoute(svg, (at) => toggleRevet("pipe", at)),
|
||||
);
|
||||
if (setShapeActive) {
|
||||
const baseSetActive = setRevetActive;
|
||||
|
||||
@@ -45,10 +45,13 @@ export function wireStructureSelection(
|
||||
/** 폼에 올린 시설로 보기를 옮김 — 목록에서 고른 길(54-47). */
|
||||
reveal?: (chainageM: number) => void,
|
||||
): { syncInletStructure: () => void } {
|
||||
const ownerChainageOf = (chainageM: number): number => {
|
||||
const ownerChainageOf = (chainageM: number, key?: RevetPick): number => {
|
||||
const sectionAt = detail()?.cross_sections.find(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < 0.01,
|
||||
);
|
||||
// 자기 관 없는 카드의 「관」 = 물넘이 콘크리트 — 걸친 연동 관보다 앞(294-7 · 되살림 길도)
|
||||
const fordM = sectionAt?.ford_pavement?.chainage_m;
|
||||
if (key === "pipe" && fordM !== undefined && !sectionAt?.culvert) return fordM;
|
||||
// 물넘이포장 범위 옆 카드 = 그 물넘이 자리(287-5 · 형상 누름이 그 시설을 엶)
|
||||
return (
|
||||
(sectionAt &&
|
||||
@@ -75,14 +78,15 @@ export function wireStructureSelection(
|
||||
};
|
||||
|
||||
const origRevet = stationControls.revetOffset.select;
|
||||
stationControls.revetOffset.select = (chainageM, key) => {
|
||||
stationControls.revetOffset.select = (chainageM, key, ownerM) => {
|
||||
// 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1)
|
||||
const nested = wallSelecting;
|
||||
// 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1)
|
||||
if (key) markWallSelecting();
|
||||
origRevet(chainageM, key);
|
||||
// 도수로 띠는 자기 측점 시설 — 이웃 관 연장 안이어도 그 관 폼을 안 엶(152-4)
|
||||
const owner = key === "chute" ? chainageM : ownerChainageOf(chainageM);
|
||||
const owner =
|
||||
ownerM ?? (key === "chute" ? chainageM : ownerChainageOf(chainageM, key ?? undefined));
|
||||
if (key) remember?.(owner, key);
|
||||
if (key) panel.showAtChainage(owner);
|
||||
else if (!nested) {
|
||||
|
||||
Reference in New Issue
Block a user