Files
Aislo/B06_Section/B06_Section_UI_Cross_Detail_Select.ts
T

491 lines
22 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_Detail_Select.ts
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로
* 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 ·
* Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴):
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」).
* 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각)
* 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움.
* 누름 판정(261-3) = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 보통 배율 얇은 띠도 영역 ·
* 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘).
* 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`).
* 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_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,
OCCLUDER_CLASSES,
pieceLabel,
} from "./B06_Section_UI_Cross_Design_Pick";
import {
findFaces,
LINE_TOLERANCE_M,
pathLength,
pieceSource,
ringArea,
splitAtCrossings,
type Face,
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";
/** 구조물 선 묶음 걸침 표시(295-3) — 조각 판정 선 위 · 누름은 거리 판정이라 걸침만. */
export const PIECE_GROUP_CLASS = "b06-detail__piece-group";
/** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */
export const FACE_HIT_CLASS = "b06-detail__face";
/** 화면 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,
];
};
}
/** m → 그림 px(`join` 하면 `x,y`). */
function mToPx(axes: CrossAxesOptions): (p: Pt) => Pt {
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 (OCCLUDER_CLASSES.some((name) => el.classList.contains(name)))
// 벽 · 집수정 · 본체 둘레(261-3) — 설계선 끝 · 비탈 밑을 잇는 구조물 선(닫힌 고리)
runs.push({
points: [...points, points[0]].map(([offset, elevation]) => ({ offset, elevation })),
structure: true,
});
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 });
}
// 구조물 선(295-3) = 다른 선을 나누는 기준은 그대로 · 자기 조각은 한 묶음(고를 때 통째)
const straight = mergeStraightPieces(runs).map((piece, index): SplitPiece => ({
line: piece.structure ? "structure" : "design",
points: [
[piece.from.offset, piece.from.elevation],
[piece.to.offset, piece.to.elevation],
],
...(piece.structure && { group: index }),
}));
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];
}
/** 묶음 고름 바꿈(295-3) — 구조물 선 조각 묶음을 한 몸으로 넣고 빼기 · 하나면 `toggleSelection` 과 같음. */
export function toggleMembers(selected: number[], members: number[], additive: boolean): number[] {
const all = members.every((m) => selected.includes(m));
if (additive)
return all
? selected.filter((i) => !members.includes(i))
: [...selected, ...members.filter((m) => !selected.includes(m))];
const same = all && selected.length === members.length;
return same ? [] : [...members];
}
/** 같은 묶음 조각 번호 — 묶음 없으면 그 하나. */
export function groupMembers(pieces: SplitPiece[], index: number): number[] {
const group = pieces[index]?.group;
if (group === undefined) return [index];
return pieces.flatMap((piece, i) => (piece.group === group ? [i] : []));
}
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 "";
// 구조물 선 한 묶음(295-3) = 선 하나 — 첫 조각 앞 끝 ~ 끝 조각 뒤 끝
const group = pieces[0].group;
if (pieces.length > 1 && group !== undefined && pieces.every((p) => p.group === group))
pieces = [{ ...pieces[0], points: [pieces[0].points[0], pieces.at(-1)!.points.at(-1)!] }];
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",
});
const horizontal = Math.abs(to[0] - from[0]);
const height = Math.abs(to[1] - from[1]);
const grade = horizontal > 0 ? `${((height / horizontal) * 100).toFixed(1)}%` : "수직";
return (
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m · 경사비율 ${grade}\n` +
`수평 ${horizontal.toFixed(2)}m · 높이 ${height.toFixed(2)}m`
);
}
const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
const area = ringArea(pieces.map((piece) => piece.points));
const units = new Set(pieces.map((piece) => piece.group ?? piece)).size;
return (
`${units}개 · 길이 합 ${total.toFixed(2)}m\n` +
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`)
);
}
/** 고른 영역 값 두 줄(259-2) — 넓이 합 / 둘레(고른 둘레 조각 길이 합 · 붙은 영역은 맞닿은 변 뺌). */
export function regionLabel(faces: Face[], pieces: SplitPiece[]): string {
const area = faces.reduce((sum, face) => sum + face.area, 0);
const around = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
const count = faces.length > 1 ? `${faces.length}개 · ` : "";
return `영역 ${count}면적 ${area.toFixed(2)}㎡\n둘레 ${around.toFixed(2)}m`;
}
/** 영역 여럿 → 둘레 조각 — 둘 이상이 맞닿은 변은 짝수 번 나와 빠짐. */
export function regionEdges(faces: Face[]): number[] {
const count = new Map<number, number>();
for (const face of faces)
for (const edge of face.edges) count.set(edge, (count.get(edge) ?? 0) + 1);
return [...count].filter(([, n]) => n % 2).map(([edge]) => edge);
}
/** 그림 조각이 저장된 조각(두 끝 · 어느 방향이든)과 같은 자리인가(259-2 표 칸 → 그래프). */
export function samePiece(piece: SplitPiece, stored: CrossPickPiece): boolean {
if (piece.line !== stored.line) return false;
const near = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M;
const [a, b] = [piece.points[0], piece.points[piece.points.length - 1]];
return (
(near(a, stored.start) && near(b, stored.end)) || (near(a, stored.end) && near(b, stored.start))
);
}
/** 고른 조각 → 칸 출처(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;
/** 저장된 조각을 그림에서 고름(259-2 표 칸 누름) · null = 비움. */
choose: (stored: CrossPickPiece[] | null) => void;
/** 고름 없이 값 글만(띠 · 259-2) — 다음 누름에 지워짐. */
note: (text: string | null) => void;
detach: () => void;
}
/** 선 누름 거리(화면 px) — 이 안이면 선 · 영역 안이면 맞은편 선까지의 ⅓ 안이어야 선(261-3). */
export const LINE_PICK_PX = 4;
const segmentGap = (p: Pt, a: Pt, b: Pt): number => {
const d = [b[0] - a[0], b[1] - a[1]];
const size2 = d[0] ** 2 + d[1] ** 2;
const t = size2
? Math.max(0, Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / size2))
: 0;
return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]);
};
const pathGap = (p: Pt, path: Pt[]): number =>
Math.min(...path.slice(1).map((q, i) => segmentGap(p, path[i], q)));
const inside = (p: Pt, ring: Pt[]): boolean =>
ring.reduce((inn, a, i) => {
const b = ring[(i + 1) % ring.length];
return a[1] > p[1] !== b[1] > p[1] &&
p[0] < a[0] + ((p[1] - a[1]) * (b[0] - a[0])) / (b[1] - a[1])
? !inn
: inn;
}, false);
/**
* 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이.
* 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다
* 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역.
* 고르기 차례(295-1 · 카드와 같은 잣대) = 선 > 작은 면 > 큰 면 · 띠 — `bandArea`(그 자리 면적 띠
* 넓이 · 그림 px²)가 영역보다 작을 때만 띠(표가 맡음).
*/
export function pickAt(
point: Pt,
scale: number,
paths: Pt[][],
rings: Pt[][],
areas: number[],
pieces: SplitPiece[],
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[] => [
piece.points[0],
piece.points[piece.points.length - 1],
];
const touches = (a: SplitPiece, b: SplitPiece): boolean =>
ends(a).some((p) =>
ends(b).some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= LINE_TOLERANCE_M),
);
const across = Math.min(
Infinity,
...gaps.filter((_, i) => i !== nearest && !touches(pieces[i], pieces[nearest])),
);
const face = rings
.flatMap((ring, i) => (inside(point, ring) ? [i] : []))
.sort((a, b) => areas[a] - areas[b])[0];
if (
paths.length &&
gaps[nearest] <= LINE_PICK_PX &&
(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(";")}` : "";
export function attachDetailSelect(input: {
cad: HTMLElement;
design: () => CrossDesign | undefined;
axes: () => CrossAxesOptions | null;
/** 조각 누름 뒤(더하기 여부) — 표가 길이 고르기 한 번 누름이면 바로 칸에 넣음. */
picked: (additive: boolean) => void;
/** [칸에 넣기] 단추. */
commit: () => void;
/** 면적 고르기 중 그 자리 면적 띠 넓이(그림 px²) · 없음 = null — 영역보다 작을 때만 띠(295-1). */
band?: (event: MouseEvent) => number | null;
}): DetailSelect {
const { cad } = input;
let pieces: SplitPiece[] = [];
let hits: SVGPolylineElement[] = [];
let faces: Face[] = [];
let faceEls: SVGPolygonElement[] = [];
/** 그림 층 · 조각 · 영역 고리(그림 px) — 누름 판정. */
let layer: SVGGElement | null = null;
let paths: Pt[][] = [];
let rings: Pt[][] = [];
let selected: number[] = [];
/** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */
let chosenFaces: number[] = [];
let noteText: string | null = null;
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)));
const same = (edges: number[]) =>
edges.length === selected.length && edges.every((edge) => selected.includes(edge));
const filled = chosenFaces.length
? chosenFaces
: faces.flatMap((face, i) => (same(face.edges) ? [i] : []));
faceEls.forEach((el, i) => el.classList.toggle("is-selected", filled.includes(i)));
label.textContent =
noteText ??
(filled.length
? regionLabel(
filled.map((i) => faces[i]),
chosen,
)
: selectionLabel(input.design(), chosen));
label.hidden = !label.textContent;
put.hidden = !mode || !selectionSource(mode, chosen);
// [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김
const bar = cad.querySelector(".b06-cross-card__zoom");
if (bar && label.parentElement !== bar) bar.prepend(label, put);
};
/** 누른 자리(그림 안만) → 선 · 영역 — 판정 선 굵기 대신 거리로(261-3). */
const pickOf = (event: MouseEvent): ReturnType<typeof pickAt> => {
const ctm = layer?.getScreenCTM();
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);
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 => {
const pick = pickOf(event);
const shift = event.shiftKey || event.ctrlKey || event.metaKey;
const additive = shift || mode === "area";
noteText = null;
if (!pick || !("piece" in pick)) {
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]));
show();
input.picked(true);
return;
}
if (!additive && !mode) [selected, chosenFaces] = [[], []];
show();
return;
}
// 선이 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬
event.stopImmediatePropagation();
chosenFaces = [];
selected = toggleMembers(selected, groupMembers(pieces, pick.piece), 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();
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] : []));
chosenFaces = [];
faces = findFaces(pieces);
const toPx = axes ? mToPx(axes) : null;
paths = toPx ? pieces.map((piece) => piece.points.map(toPx)) : [];
rings = toPx ? faces.map((face) => face.ring.map(toPx)) : [];
// 영역 판정 면 먼저(밑) → 조각 판정 선(위)
faceEls = rings.map((ring) => {
const el = document.createElementNS(SVG_NS, "polygon");
el.setAttribute("points", ring.join(" "));
el.setAttribute("class", FACE_HIT_CLASS);
layer?.append(el);
return el;
});
hits = paths.map((path, i) => {
const hit = document.createElementNS(SVG_NS, "polyline");
hit.setAttribute("points", path.join(" "));
hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${pieces[i].line}`);
layer?.append(hit);
return hit;
});
// 구조물 선 묶음 걸침 표시(295-3) — 조각 대신 선 하나로 비침
new Set(pieces.flatMap((piece) => (piece.group === undefined ? [] : [piece.group]))).forEach(
(group) => {
const members = pieces.flatMap((piece, i) => (piece.group === group ? [i] : []));
const el = document.createElementNS(SVG_NS, "polyline");
el.setAttribute(
"points",
[paths[members[0]][0], paths[members.at(-1)!].at(-1)!].join(" "),
);
el.setAttribute("class", PIECE_GROUP_CLASS);
layer?.append(el);
},
);
show();
},
setMode(kind) {
mode = kind;
show();
},
source: (kind) =>
selectionSource(
kind,
selected.map((i) => pieces[i]),
),
clear() {
[selected, chosenFaces] = [[], []];
show();
},
choose(stored) {
chosenFaces = [];
noteText = null;
selected = stored
? pieces.flatMap((piece, i) => (stored.some((s) => samePiece(piece, s)) ? [i] : []))
: [];
show();
},
note(text) {
noteText = text;
show();
},
detach() {
cad.removeEventListener("click", onClick);
},
};
}