- 가운데 단추 팬 뒤 왼쪽 누름 click 삼킴 없앰(거리이정표 다이어그램 같은 줄) - 암 경계선 조각 고름 · 서버 pick_lines rock 짝 · 스키마 · 타입 rock Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Detail_Select.ts
|
|
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로
|
|
* 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 ·
|
|
* Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴):
|
|
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」).
|
|
* 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀).
|
|
* ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때.
|
|
* ========================================================================== */
|
|
|
|
import type { CrossDesign, CrossPickPiece } from "./B06_Section_Api_Types";
|
|
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
|
|
import { CROSS_PAD } from "./B06_Section_UI_Section_Common";
|
|
import {
|
|
DESIGN_LINE_ATTR,
|
|
mergeStraightPieces,
|
|
pieceLabel,
|
|
} from "./B06_Section_UI_Cross_Design_Pick";
|
|
import {
|
|
pathLength,
|
|
pieceSource,
|
|
ringArea,
|
|
splitAtCrossings,
|
|
type Pt,
|
|
type SplitPiece,
|
|
} from "./B06_Section_UI_Cross_Detail_Geom";
|
|
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
export const PIECE_HIT_CLASS = "b06-detail__piece";
|
|
|
|
/** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */
|
|
export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt {
|
|
return ([px, py]) => {
|
|
const display = axes.displayMax - (py - CROSS_PAD.top) / axes.pixelsPerMeter;
|
|
return [
|
|
axes.maxOffset - (px - CROSS_PAD.left) / axes.pixelsPerMeter,
|
|
axes.elevationMid + (display - axes.elevationMid) / axes.exaggeration,
|
|
];
|
|
};
|
|
}
|
|
|
|
function mToPx(axes: CrossAxesOptions): (p: Pt) => string {
|
|
return ([offset, elevation]) => {
|
|
const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration;
|
|
const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter;
|
|
const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter;
|
|
return `${x},${y}`;
|
|
};
|
|
}
|
|
|
|
function pointsOf(el: Element): number[][] {
|
|
if (el.tagName.toLowerCase() === "line") {
|
|
const n = (name: string) => Number(el.getAttribute(name));
|
|
return [
|
|
[n("x1"), n("y1")],
|
|
[n("x2"), n("y2")],
|
|
];
|
|
}
|
|
return (el.getAttribute("points") ?? "")
|
|
.trim()
|
|
.split(/\s+/)
|
|
.map((pair) => pair.split(",").map(Number))
|
|
.filter((p) => p.length === 2 && p.every(Number.isFinite));
|
|
}
|
|
|
|
/** 그림 도형 층 요소 → 고를 조각(m) — 설계 · 구조물 선은 같은 방향 토막을 직선 하나로 합친 뒤 나눔. */
|
|
export function collectPieces(elements: Element[], axes: CrossAxesOptions): SplitPiece[] {
|
|
const toM = pxToM(axes);
|
|
const runs: Parameters<typeof mergeStraightPieces>[0] = [];
|
|
const others: SplitPiece[] = [];
|
|
for (const el of elements) {
|
|
const points = pointsOf(el).map(toM);
|
|
if (points.length < 2) continue;
|
|
if (el.classList.contains("b06-chart__design-cross"))
|
|
runs.push({
|
|
points: points.map(([offset, elevation]) => ({ offset, elevation })),
|
|
structure: !el.hasAttribute(DESIGN_LINE_ATTR),
|
|
});
|
|
else if (el.classList.contains("b06-chart__cross-profile"))
|
|
others.push({ line: "ground", points });
|
|
else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points });
|
|
else if (el.classList.contains("b06-chart__rock-boundary"))
|
|
others.push({ line: "rock", points });
|
|
}
|
|
const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({
|
|
line: piece.structure ? "structure" : "design",
|
|
points: [
|
|
[piece.from.offset, piece.from.elevation],
|
|
[piece.to.offset, piece.to.elevation],
|
|
],
|
|
}));
|
|
return splitAtCrossings([...straight, ...others]);
|
|
}
|
|
|
|
/** 고름 바꿈 — 더하기(Shift/Ctrl) = 넣고 빼기 · 아니면 그 하나(다시 누르면 비움). */
|
|
export function toggleSelection(selected: number[], index: number, additive: boolean): number[] {
|
|
if (additive)
|
|
return selected.includes(index) ? selected.filter((i) => i !== index) : [...selected, index];
|
|
return selected.length === 1 && selected[0] === index ? [] : [index];
|
|
}
|
|
|
|
const LINE_NAME: Record<SplitPiece["line"], string> = {
|
|
design: "설계선",
|
|
structure: "구조물 선",
|
|
ground: "원지반선",
|
|
spoil: "사토장선",
|
|
rock: "암 경계선",
|
|
};
|
|
|
|
/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */
|
|
export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPiece[]): string {
|
|
if (!pieces.length) return "";
|
|
if (pieces.length === 1) {
|
|
const [piece] = pieces;
|
|
const from = piece.points[0];
|
|
const to = piece.points[piece.points.length - 1];
|
|
if (
|
|
design &&
|
|
piece.points.length === 2 &&
|
|
(piece.line === "design" || piece.line === "structure")
|
|
)
|
|
return pieceLabel(design, {
|
|
from: { offset: from[0], elevation: from[1] },
|
|
to: { offset: to[0], elevation: to[1] },
|
|
structure: piece.line === "structure",
|
|
});
|
|
return (
|
|
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` +
|
|
`수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m`
|
|
);
|
|
}
|
|
const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
|
|
const area = ringArea(pieces.map((piece) => piece.points));
|
|
return (
|
|
`${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` +
|
|
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`)
|
|
);
|
|
}
|
|
|
|
/** 고른 조각 → 칸 출처(209-1 저장 꼴) — 면적은 닫혀야 · 길이는 하나 이상. */
|
|
export function selectionSource(
|
|
kind: "area" | "length",
|
|
pieces: SplitPiece[],
|
|
): { src: "area" | "length"; pieces: CrossPickPiece[] } | null {
|
|
if (!pieces.length) return null;
|
|
if (kind === "area" && ringArea(pieces.map((piece) => piece.points)) === null) return null;
|
|
return { src: kind, pieces: pieces.map(pieceSource) };
|
|
}
|
|
|
|
/* ── 모달 배선 ─────────────────────────────────────────────────────────── */
|
|
|
|
export interface DetailSelect {
|
|
/** 그림을 새로 그린 뒤 — 조각 · 판정 선 · 값 칸 다시(같은 자리 조각 고름은 유지 · 233-2). */
|
|
redraw: () => void;
|
|
/** 고르기 갈래(표 우클릭) — 있으면 [칸에 넣기] 단추 · 면적이면 누름 = 늘 더하기. */
|
|
setMode: (kind: "area" | "length" | null) => void;
|
|
source: (kind: "area" | "length") => ReturnType<typeof selectionSource>;
|
|
clear: () => void;
|
|
detach: () => void;
|
|
}
|
|
|
|
/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */
|
|
const pieceKey = (piece: SplitPiece | undefined): string =>
|
|
piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : "";
|
|
|
|
export function attachDetailSelect(input: {
|
|
cad: HTMLElement;
|
|
design: () => CrossDesign | undefined;
|
|
axes: () => CrossAxesOptions | null;
|
|
/** 조각 누름 뒤(더하기 여부) — 표가 길이 고르기 한 번 누름이면 바로 칸에 넣음. */
|
|
picked: (additive: boolean) => void;
|
|
/** [칸에 넣기] 단추. */
|
|
commit: () => void;
|
|
}): DetailSelect {
|
|
const { cad } = input;
|
|
let pieces: SplitPiece[] = [];
|
|
let hits: SVGPolylineElement[] = [];
|
|
let selected: number[] = [];
|
|
let mode: "area" | "length" | null = null;
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "b06-cross-card__line-dims b06-detail__measure";
|
|
const put = document.createElement("button");
|
|
put.type = "button";
|
|
put.className = "b06-cross-card__zoom-btn b06-detail__commit";
|
|
put.textContent = "칸에 넣기";
|
|
put.title = "고른 선을 칸에 넣음(Enter)";
|
|
put.addEventListener("click", (event) => {
|
|
event.stopPropagation();
|
|
input.commit();
|
|
});
|
|
|
|
const show = (): void => {
|
|
const chosen = selected.map((i) => pieces[i]);
|
|
hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i)));
|
|
label.textContent = selectionLabel(input.design(), chosen);
|
|
label.hidden = !chosen.length;
|
|
put.hidden = !mode || !selectionSource(mode, chosen);
|
|
// [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김
|
|
const bar = cad.querySelector(".b06-cross-card__zoom");
|
|
if (bar && label.parentElement !== bar) bar.prepend(label, put);
|
|
};
|
|
|
|
const onClick = (event: MouseEvent): void => {
|
|
const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`);
|
|
const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area";
|
|
if (!hit) {
|
|
if (!additive && !mode && selected.length) {
|
|
selected = [];
|
|
show();
|
|
}
|
|
return;
|
|
}
|
|
event.stopPropagation();
|
|
selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive);
|
|
show();
|
|
input.picked(additive);
|
|
};
|
|
cad.addEventListener("click", onClick);
|
|
|
|
return {
|
|
redraw() {
|
|
// 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2)
|
|
const keep = new Set(selected.map((i) => pieceKey(pieces[i])));
|
|
const axes = input.axes();
|
|
const layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
|
|
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
|
|
selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : []));
|
|
const toPx = axes ? mToPx(axes) : null;
|
|
hits = toPx
|
|
? pieces.map((piece) => {
|
|
const hit = document.createElementNS(SVG_NS, "polyline");
|
|
hit.setAttribute("points", piece.points.map(toPx).join(" "));
|
|
hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`);
|
|
layer?.append(hit);
|
|
return hit;
|
|
})
|
|
: [];
|
|
show();
|
|
},
|
|
setMode(kind) {
|
|
mode = kind;
|
|
show();
|
|
},
|
|
source: (kind) =>
|
|
selectionSource(
|
|
kind,
|
|
selected.map((i) => pieces[i]),
|
|
),
|
|
clear() {
|
|
selected = [];
|
|
show();
|
|
},
|
|
detach() {
|
|
cad.removeEventListener("click", onClick);
|
|
},
|
|
};
|
|
}
|