Files
Aislo/B06_Section/B06_Section_UI_Cross_Design_Pick.ts
T

359 lines
14 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_Design_Pick.ts
* 횡단 카드 **보라 요소 고름**(설계선 · 구조물 트림 선 · 137-2).
*
* 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을
* 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 적는다.
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
* 고른 조각의 쪽 노견을 [◀ ▶] 0.1 m 로 넓히고 좁힘(139-6 쉬프트 · 노폭 안쪽 금지 = 0 에서 멈춤).
* ========================================================================== */
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import { clipSegment, type XY } from "../A00_Common/b_polygon_clip";
import { sideRole } from "@util/common_util_cross_design_geometry";
const SVG_NS = "http://www.w3.org/2000/svg";
const DESIGN_CLASS = "b06-chart__design-cross";
const HIT_CLASS = "b06-chart__design-hit";
/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */
export const DESIGN_LINE_ATTR = "data-design-line";
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
const OCCLUDER_CLASSES = [
"b06-chart__culvert-revet",
"b06-chart__culvert-basin",
"b06-chart__ford-slab",
"b06-chart__box-barrel",
];
/** 같은 직선으로 보는 꺾임 허용(m) — 설계선 표고는 0.1 mm 반올림이라 넉넉하다. */
const STRAIGHT_TOL_M = 0.005;
/** 가리고 남은 조각이 이보다 짧으면 버린다(m) — 누를 수도 없다. */
const MIN_PIECE_M = 0.02;
export interface CrossPoint {
offset: number;
elevation: number;
}
/** 그려진 보라 선 한 줄(m 좌표). `structure` = 구조물 트림 선(설계선 오버레이 밖). */
export interface DesignRun {
points: CrossPoint[];
structure: boolean;
}
/** 직선 조각 — 두 끝(m) · 구조물 트림 선인가. */
export interface DesignPiece {
from: CrossPoint;
to: CrossPoint;
structure: boolean;
}
export type CrossZone = "road" | "cut" | "fill";
export type CrossSide = "left" | "right";
type ZoneDesign = Pick<
CrossDesign,
"road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode"
>;
/**
* offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 +
* 구조물 트림 선. 사면 역할은 단면유형(`sideRole`)으로 · 쪽 = +offset 좌.
*/
export function crossZoneAt(
design: ZoneDesign,
offsetM: number,
structure = false,
): { zone: CrossZone; side: CrossSide } {
const side: CrossSide = offsetM >= 0 ? "left" : "right";
const edges = design.road_edges;
const hi = edges ? Math.max(edges.left.offset_m, edges.right.offset_m) : 0;
const lo = edges ? Math.min(edges.left.offset_m, edges.right.offset_m) : 0;
if (structure) return { zone: "fill", side };
if (offsetM >= lo - 1e-6 && offsetM <= hi + 1e-6) return { zone: "road", side };
const ditch = design.ditch;
const ditchWidth =
design.ditch_enabled === false || !ditch || ditch.type === "none"
? 0
: ditch.type === "standard"
? ditch.top_width_m
: ditch.width_m;
if (design.ditch_side === "left" && offsetM > hi && offsetM <= hi + ditchWidth + 1e-6)
return { zone: "cut", side };
if (design.ditch_side === "right" && offsetM < lo && offsetM >= lo - ditchWidth - 1e-6)
return { zone: "cut", side };
let roles: [string, string] = ["fill", "fill"];
try {
roles = sideRole(design.section_mode);
} catch {
// 모르는 단면유형 = 성토로 봄
}
return { zone: (side === "left" ? roles[0] : roles[1]) === "cut" ? "cut" : "fill", side };
}
/** 조각 구역 = 가운데 자리의 구역. */
export const pieceZone = (design: ZoneDesign, piece: DesignPiece) =>
crossZoneAt(design, (piece.from.offset + piece.to.offset) / 2, piece.structure);
const gap = (a: CrossPoint, b: CrossPoint): number =>
Math.hypot(a.offset - b.offset, a.elevation - b.elevation);
/** 점열이 한 직선 위에서 한 방향으로만 가는가(되접힘 없음). */
function isStraight(points: CrossPoint[]): boolean {
const a = points[0];
const b = points[points.length - 1];
const length = gap(a, b);
if (length < 1e-9) return false;
const ux = (b.offset - a.offset) / length;
const uy = (b.elevation - a.elevation) / length;
let along = -STRAIGHT_TOL_M;
for (const p of points) {
const dx = p.offset - a.offset;
const dy = p.elevation - a.elevation;
if (Math.abs(ux * dy - uy * dx) > STRAIGHT_TOL_M) return false;
const t = ux * dx + uy * dy;
if (t < along - STRAIGHT_TOL_M) return false;
along = Math.max(along, t);
}
return true;
}
/** 그려진 선들 → 같은 방향 토막을 합친 직선 조각(설계선 · 구조물 선은 따로 합침). */
export function mergeStraightPieces(runs: DesignRun[]): DesignPiece[] {
const chains: Array<{ points: CrossPoint[]; structure: boolean }> = [];
for (const run of runs) {
for (let i = 1; i < run.points.length; i += 1) {
const start = run.points[i - 1];
const end = run.points[i];
if (gap(start, end) < 1e-6) continue;
const last = chains[chains.length - 1];
if (
last &&
last.structure === run.structure &&
gap(last.points[last.points.length - 1], start) < 1e-3 &&
isStraight([...last.points, end])
)
last.points.push(end);
else chains.push({ points: [start, end], structure: run.structure });
}
}
// 차례가 떨어진 토막도 끝이 닿고 한 직선이면 합친다(방향이 거꾸로 그려진 선 포함).
const joined = (p: CrossPoint[], q: CrossPoint[]): CrossPoint[] | null => {
for (const a of [p, [...p].reverse()])
for (const b of [q, [...q].reverse()])
if (gap(a[a.length - 1], b[0]) < 1e-3 && isStraight([...a, ...b.slice(1)]))
return [...a, ...b.slice(1)];
return null;
};
let merged = true;
while (merged) {
merged = false;
for (let i = 0; i < chains.length && !merged; i += 1)
for (let j = i + 1; j < chains.length && !merged; j += 1) {
if (chains[i].structure !== chains[j].structure) continue;
const points = joined(chains[i].points, chains[j].points);
if (!points) continue;
chains[i] = { points, structure: chains[i].structure };
chains.splice(j, 1);
merged = true;
}
}
return chains.map((chain) => ({
from: chain.points[0],
to: chain.points[chain.points.length - 1],
structure: chain.structure,
}));
}
/** 벽 · 본체 폴리곤(m)에 가린 부분을 뺀 **보이는 조각**만. */
export function visiblePieces(pieces: DesignPiece[], occluders: CrossPoint[][]): DesignPiece[] {
const polygons = occluders
.filter((polygon) => polygon.length >= 3)
.map((polygon) => polygon.map((p) => [p.offset, p.elevation] as XY));
const out: DesignPiece[] = [];
for (const piece of pieces) {
let runs: XY[][] = [
[
[piece.from.offset, piece.from.elevation],
[piece.to.offset, piece.to.elevation],
],
];
for (const polygon of polygons)
runs = runs.flatMap(([a, b]) => clipSegment(a, b, polygon, "outside"));
for (const [a, b] of runs) {
const from = { offset: a[0], elevation: a[1] };
const to = { offset: b[0], elevation: b[1] };
if (gap(from, to) >= MIN_PIECE_M) out.push({ from, to, structure: piece.structure });
}
}
return out;
}
/** 치수 — 사면길이 · 수평 · 높이(m) · 구배(사면 1:n · 노면 %). */
export function pieceMeasure(
piece: DesignPiece,
zone: CrossZone,
): { lengthM: number; horizontalM: number; heightM: number; grade: string } {
const horizontalM = Math.abs(piece.to.offset - piece.from.offset);
const heightM = Math.abs(piece.to.elevation - piece.from.elevation);
const grade =
zone === "road"
? horizontalM < 1e-6
? "—"
: `${((heightM / horizontalM) * 100).toFixed(1)}%`
: heightM < 1e-4
? "0.0%"
: `1:${(horizontalM / heightM).toFixed(2)}`;
return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade };
}
export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string {
const { zone } = pieceZone(design, piece);
const m = pieceMeasure(piece, zone);
return (
`${zone === "road" ? "길이" : "사면길이"} ${m.lengthM.toFixed(2)}m · ` +
`수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m · ${m.grade}`
);
}
/* ── 페이지 한 칸 고름 — 측점 + 조각 번호(카드를 다시 그려도 되살림 · 137-3) ───────── */
let picked: { stationId: string; index: number } | null = null;
export const pickedLineAt = (stationId: string): number | null =>
picked?.stationId === stationId ? picked.index : null;
export function setPickedLine(stationId: string, index: number | null): void {
if (index !== null) picked = { stationId, index };
else if (picked?.stationId === stationId) picked = null;
}
/* ── 카드 배선 ─────────────────────────────────────────────────────────── */
const pointsOf = (element: Element): XY[] => {
if (element.tagName.toLowerCase() === "line") {
const n = (name: string) => Number(element.getAttribute(name));
return [
[n("x1"), n("y1")],
[n("x2"), n("y2")],
];
}
return (element.getAttribute("points") ?? "")
.trim()
.split(/\s+/)
.map((pair) => pair.split(",").map(Number) as XY)
.filter((p) => p.length === 2 && p.every(Number.isFinite));
};
/** 설계선 쉬프트(139-6) — 쪽마다 노견 확장(m · ≥ 0). */
export interface ShoulderShift {
valueOf: (side: CrossSide) => number;
set: (side: CrossSide, valueM: number) => void;
}
/** 쉬프트 걸음(m). */
export const SHIFT_STEP_M = 0.1;
/** 화면 ◀(−1) · ▶(+1) 한 걸음 → 그 쪽 노견 확장 새 값(0.1 m 눈금 · 0 아래 금지).
* `kx` = 화면 x / offset 기울기(+offset = 좌 · 화면 왼쪽이면 kx < 0). */
export function shiftedExtend(now: number, side: CrossSide, screenDir: -1 | 1, kx: number): number {
const offsetDelta = screenDir * Math.sign(kx) * SHIFT_STEP_M;
const outward = side === "left" ? offsetDelta : -offsetDelta;
return Math.max(0, Math.round((now + outward) * 10) / 10);
}
export interface DesignLinePick {
pieces: DesignPiece[];
/** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */
label: HTMLElement;
setActive: (index: number | null) => void;
}
/**
* 그려진 보라 선에 고름을 단다 — 벽 · 관 등을 다 그린 **뒤**(가림 · 판정면 차례) 부른다.
* `x` · `toDisplayY` = 카드 좌표 함수(1차식 — 역산해 m 로 되돌림).
*/
export function attachDesignLinePick(input: {
layer: SVGElement;
design: ZoneDesign;
x: (offset: number) => number;
toDisplayY: (elevation: number) => number;
onPick: (index: number) => void;
/** 있으면 고른 조각 쪽 노견을 [◀ ▶] 로 옮김(139-6). */
shift?: ShoulderShift;
}): DesignLinePick {
const { layer, design, x, toDisplayY } = input;
const x0 = x(0);
const kx = x(1) - x0;
const y0 = toDisplayY(0);
const ky = toDisplayY(1) - y0;
const toM = ([px, py]: XY): CrossPoint => ({ offset: (px - x0) / kx, elevation: (py - y0) / ky });
const kids = [...layer.children];
const runs: DesignRun[] = kids
.filter((el) => el.classList.contains(DESIGN_CLASS))
.map((el) => ({
points: pointsOf(el).map(toM),
structure: !el.hasAttribute(DESIGN_LINE_ATTR),
}));
const occluders = kids
.filter(
(el) =>
el.tagName.toLowerCase() === "polygon" &&
OCCLUDER_CLASSES.some((name) => el.classList.contains(name)),
)
.map((el) => pointsOf(el).map(toM));
const pieces = visiblePieces(mergeStraightPieces(runs), occluders);
// 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김.
const firstStructureHit = kids.find((el) =>
el.classList.contains("b06-chart__culvert-revet-hit"),
);
const hits = pieces.map((piece, index) => {
const hit = document.createElementNS(SVG_NS, "polyline");
hit.setAttribute(
"points",
[piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "),
);
hit.setAttribute("class", HIT_CLASS);
hit.addEventListener("click", (event) => {
event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음
input.onPick(index);
});
layer.insertBefore(hit, firstStructureHit ?? null);
return hit;
});
const label = document.createElement("span");
label.className = "b06-cross-card__line-dims";
label.hidden = true;
const text = document.createElement("span");
label.append(text);
let side: CrossSide | null = null;
const { shift } = input;
if (shift) {
const button = (glyph: string, dir: -1 | 1, tip: string): HTMLButtonElement => {
const el = document.createElement("button");
el.type = "button";
el.className = "b06-cross-card__zoom-btn b06-cross-card__line-shift";
el.textContent = glyph;
el.title = tip;
el.addEventListener("click", (event) => {
event.stopPropagation();
if (side) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx));
});
return el;
};
label.append(
button("◀", -1, "고른 선 쪽 노견을 화면 왼쪽으로 0.1 m(노폭 안쪽은 못 감)"),
button("▶", 1, "고른 선 쪽 노견을 화면 오른쪽으로 0.1 m(노폭 안쪽은 못 감)"),
);
}
return {
pieces,
label,
setActive: (index) => {
const piece = index === null ? undefined : pieces[index];
hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index));
label.hidden = !piece;
side = piece ? pieceZone(design, piece).side : null;
const extend = side && shift ? shift.valueOf(side) : 0;
text.textContent = piece
? pieceLabel(design, piece) + (extend > 0 ? ` · 노견 +${extend.toFixed(1)}m` : "")
: "";
},
};
}