설계선 끝 ~ 깎기 비탈 밑을 집수정 둘레가 이어 깎기 암석(암선 ↔ 설계선) · 깎기 토사(지반 ↔ 암선) 영역이 따로 닫힘. 배수관 측점(검증_랩탑메인 4+15) 깎기 암석 영역 18.24㎡ 생김 · 일반 깎기 측점은 그대로(그림 칠 면적과 같음). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
564 lines
24 KiB
TypeScript
564 lines
24 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Design_Pick.ts
|
|
* 횡단 카드 **보라 요소 고름**(설계선 · 구조물 트림 선 · 137-2).
|
|
*
|
|
* 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을
|
|
* 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정
|
|
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 두 줄로 적는다.
|
|
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
|
|
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
|
|
* 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 ·
|
|
* 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 ·
|
|
* 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고).
|
|
* 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬.
|
|
* 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2).
|
|
* ========================================================================== */
|
|
|
|
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
|
|
import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design";
|
|
import { clipSegment, type XY } from "../A00_Common/b_polygon_clip";
|
|
import { sideRole } from "@util/common_util_cross_design_geometry";
|
|
import { BERM_DEFAULT_DEPTH_M } from "@util/common_util_cross_berm";
|
|
import {
|
|
arrowsBelongToProfile,
|
|
bindArrowKeys,
|
|
buildPanelShell,
|
|
dpadButton,
|
|
makeRow,
|
|
} from "./B06_Section_UI_Cross_Panel_Base";
|
|
|
|
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";
|
|
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
|
|
export 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"
|
|
> &
|
|
Partial<Pick<CrossDesign, "berm" | "berm_depth_left_m" | "berm_depth_right_m">>;
|
|
|
|
/**
|
|
* 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);
|
|
|
|
/** 고른 선이 움직이는 축(193-1) — 도로측(노면 · 노견) = 높이(y · 종단 계획고 ▲▼) ·
|
|
* 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */
|
|
export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis =>
|
|
isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x";
|
|
/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1). */
|
|
export type MoveAxis = "x" | "y" | "depth";
|
|
|
|
/** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */
|
|
export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number =>
|
|
design[`berm_depth_${side}_m`] ?? design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M;
|
|
|
|
/** 소단 수평 단인가 — 성토측 · 폭 = 소단 폭 · 안쪽 끝 표고 = 노면 끝 − 깊이(194-1). */
|
|
export function isBermPiece(design: ZoneDesign, piece: DesignPiece): boolean {
|
|
const berm = design.berm;
|
|
if (!berm || piece.structure) return false;
|
|
const { zone, side } = pieceZone(design, piece);
|
|
const edge = design.road_edges?.[side];
|
|
if (zone !== "fill" || !edge) return false;
|
|
const rise = Math.tan((berm.slope_deg * Math.PI) / 180) * berm.width_m;
|
|
const low = Math.min(piece.from.elevation, piece.to.elevation);
|
|
const high = Math.max(piece.from.elevation, piece.to.elevation);
|
|
return (
|
|
Math.abs(Math.abs(piece.to.offset - piece.from.offset) - berm.width_m) < 0.05 &&
|
|
Math.abs(low - (edge.elevation_m - bermDepthOf(design, side))) < 0.02 &&
|
|
high - low <= rise + 0.01
|
|
);
|
|
}
|
|
|
|
/** 조각 키 = 갈래 + 쪽 + 차례(가운데에서 바깥으로) — 다시 그려도 안 바뀜(194-2). */
|
|
export function pieceKeys(design: ZoneDesign, pieces: DesignPiece[]): string[] {
|
|
const mid = (p: DesignPiece) => Math.abs(p.from.offset + p.to.offset) / 2;
|
|
const kinds = pieces.map((p) => {
|
|
const { zone, side } = pieceZone(design, p);
|
|
return `${isBermPiece(design, p) ? "berm" : zone}${p.structure ? "+s" : ""}:${side}`;
|
|
});
|
|
return pieces.map((p, i) => {
|
|
const rank = pieces.filter(
|
|
(q, j) => kinds[j] === kinds[i] && (mid(q) < mid(p) || (mid(q) === mid(p) && j < i)),
|
|
).length;
|
|
return `${kinds[i]}:${rank}`;
|
|
});
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
/** 두 줄(151-1 · 195-1) — 1줄 = 길이 · 경사비율 · 2줄 = 수평 · 높이(줄바꿈 글자로 가름). */
|
|
export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string {
|
|
const { zone } = pieceZone(design, piece);
|
|
const m = pieceMeasure(piece, zone);
|
|
return (
|
|
`길이 ${m.lengthM.toFixed(2)}m · 경사비율 ${m.grade}\n` +
|
|
`수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m`
|
|
);
|
|
}
|
|
|
|
/* ── 페이지 한 칸 고름 — 측점 + 조각 키(카드를 다시 그려도 되살림 · 137-3) ───────────
|
|
* 번호가 아니라 키(`pieceKeys`)로 기억 — 옮겨 다시 그리면 조각 차례가 바뀌어도 같은 조각(194-2).
|
|
* 키를 안 적은 측점(카드 밖 시험)은 번호 그대로. */
|
|
let picked: { stationId: string; key: string } | null = null;
|
|
const keysByStation = new Map<string, string[]>();
|
|
export function pickedLineAt(stationId: string): number | null {
|
|
if (picked?.stationId !== stationId) return null;
|
|
const keys = keysByStation.get(stationId);
|
|
const index = keys ? keys.indexOf(picked.key) : Number(picked.key);
|
|
return index >= 0 ? index : null;
|
|
}
|
|
export function setPickedLine(stationId: string, index: number | null): void {
|
|
if (index !== null) {
|
|
const keys = keysByStation.get(stationId);
|
|
picked = { stationId, key: keys ? (keys[index] ?? "") : String(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 · 노견째 · 151-2). */
|
|
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);
|
|
}
|
|
|
|
/** 소단 높이(194-1) — 쪽마다 깊이(노면 끝 아래 m · ≥ 0) · 고름 켬/끔을 좌측 소단 상자에 알림. */
|
|
export interface BermDepthControl {
|
|
set: (side: CrossSide, depthM: number) => void;
|
|
focus: (on: boolean) => void;
|
|
}
|
|
|
|
/** 소단 고름 → 좌측 패널 소단 상자 켬(194-2) — 페이지가 단다(측점 · null = 끔). */
|
|
let bermHost: ((chainageM: number | null) => void) | null = null;
|
|
let bermHostAt: number | null = null;
|
|
export function setBermPickHost(host: ((chainageM: number | null) => void) | null): void {
|
|
bermHost = host;
|
|
bermHostAt = null;
|
|
}
|
|
function tellBermHost(chainageM: number | null): void {
|
|
if (chainageM === bermHostAt) return; // 다시 그린 카드의 되살림은 한 번만
|
|
bermHostAt = chainageM;
|
|
bermHost?.(chainageM);
|
|
}
|
|
|
|
/** 카드 → 고름 이동 창구 — 노견 확장(139-6) · 소단 깊이(194-1) 둘 다 카드 버튼과 같은 창구. */
|
|
export function designLineMoves(
|
|
section: Pick<CrossSection, "station_id" | "chainage_m" | "design">,
|
|
onDesignChange?: (chainageM: number, change: CrossDesignChange) => void,
|
|
): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl } {
|
|
const design = section.design;
|
|
if (!onDesignChange || !design) return { stationId: section.station_id };
|
|
const send = (change: Partial<CrossDesignChange>) =>
|
|
onDesignChange(section.chainage_m, {
|
|
...(design as unknown as CrossDesignChange),
|
|
...change,
|
|
});
|
|
return {
|
|
stationId: section.station_id,
|
|
shift: {
|
|
valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0,
|
|
set: (side, valueM) => send({ shoulder_extend: { side, valueM } }),
|
|
},
|
|
berm: {
|
|
set: (side, valueM) => send({ berm_depth: { side, valueM } }),
|
|
// 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움
|
|
focus: (on) => {
|
|
if (on) tellBermHost(section.chainage_m);
|
|
else if (bermHostAt === section.chainage_m) tellBermHost(null);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface DesignLinePick {
|
|
pieces: DesignPiece[];
|
|
/** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */
|
|
label: HTMLElement;
|
|
/** 사면 이동 패널(151-3) — 카드가 그림 칸에 붙임 · `shift` 없거나 고름 없으면 숨김. */
|
|
panel: 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 · 151-3). */
|
|
shift?: ShoulderShift;
|
|
/** 있으면 고른 소단을 ▲▼ 로 옮김(194-1). */
|
|
berm?: BermDepthControl;
|
|
/** 있으면 고름을 조각 키로 기억(194-2 · `pickedLineAt`). */
|
|
stationId?: string;
|
|
}): 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);
|
|
if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces));
|
|
|
|
// 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김.
|
|
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;
|
|
let side: CrossSide | null = null;
|
|
let axis: MoveAxis = "x";
|
|
let active: number | null = null;
|
|
const { shift, berm } = input;
|
|
|
|
// 사면 이동 패널(151-3) — B06 공용 조정창 꼴(머리 · 「이동」 줄 십자 · 방향키 ←→).
|
|
// ✕ = 같은 조각 다시 고름(카드가 풂) · ↺ = 그 쪽 0.
|
|
const shell = buildPanelShell(() => {
|
|
if (active !== null) input.onPick(active);
|
|
});
|
|
const readout = document.createElement("div");
|
|
readout.className = "b06-structure-panel__value";
|
|
const move = makeRow("이동");
|
|
move.controls.classList.add("b06-structure-panel__buttons");
|
|
const step = (dir: -1 | 1) => () => {
|
|
if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx));
|
|
};
|
|
move.controls.append(
|
|
dpadButton(
|
|
"◀",
|
|
"left",
|
|
"고른 사면을 화면 왼쪽으로 0.1 m(노견째 · 노폭 안쪽은 못 감)",
|
|
step(-1),
|
|
),
|
|
dpadButton("↺", "reset", "이 쪽 이동 0", () => {
|
|
if (side && shift) shift.set(side, 0);
|
|
}),
|
|
dpadButton(
|
|
"▶",
|
|
"right",
|
|
"고른 사면을 화면 오른쪽으로 0.1 m(노견째 · 노폭 안쪽은 못 감)",
|
|
step(1),
|
|
),
|
|
);
|
|
// 소단 높이 줄(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤)
|
|
const lift = makeRow("높이");
|
|
lift.controls.classList.add("b06-structure-panel__buttons");
|
|
const toeZ = (at: CrossSide): number =>
|
|
Math.min(
|
|
...pieces
|
|
.filter((p) => !p.structure && pieceZone(design, p).side === at)
|
|
.flatMap((p) => [p.from.elevation, p.to.elevation]),
|
|
);
|
|
const deepen = (dir: -1 | 1) => () => {
|
|
if (!side || !berm) return;
|
|
const now = bermDepthOf(design, side);
|
|
const next = Math.max(0, Math.round((now + dir * SHIFT_STEP_M) * 10) / 10);
|
|
const edgeZ = design.road_edges?.[side]?.elevation_m;
|
|
if (dir > 0 && edgeZ !== undefined && edgeZ - next < toeZ(side) + SHIFT_STEP_M) return;
|
|
if (next !== now) berm.set(side, next);
|
|
};
|
|
lift.controls.append(
|
|
dpadButton("▲", "up", "고른 소단을 0.1 m 위로(노면 끝보다 높을 수 없음)", deepen(-1)),
|
|
dpadButton("↺", "reset", "이 쪽 소단 = 구간 기본 깊이", () => {
|
|
if (side && berm) berm.set(side, design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M);
|
|
}),
|
|
dpadButton("▼", "down", "고른 소단을 0.1 m 아래로", deepen(1)),
|
|
);
|
|
shell.root.append(readout, move.row, lift.row);
|
|
const arrows = bindArrowKeys(shell.root, () => [move.controls, lift.controls]);
|
|
// 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감.
|
|
// 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김.
|
|
const blockOffAxis = (event: KeyboardEvent): void => {
|
|
if (!shell.root.isConnected) {
|
|
document.removeEventListener("keydown", blockOffAxis);
|
|
return;
|
|
}
|
|
if (shell.root.classList.contains("is-hidden") || arrowsBelongToProfile()) return;
|
|
const focus = event.target as HTMLElement | null;
|
|
if (focus && (focus.tagName === "SELECT" || focus.tagName === "INPUT")) return;
|
|
const vertical = event.key === "ArrowUp" || event.key === "ArrowDown";
|
|
const horizontal = event.key === "ArrowLeft" || event.key === "ArrowRight";
|
|
if (axis === "x" ? vertical : horizontal) event.preventDefault();
|
|
};
|
|
|
|
return {
|
|
pieces,
|
|
label,
|
|
panel: shell.root,
|
|
setActive: (index) => {
|
|
const piece = index === null ? undefined : pieces[index];
|
|
hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index));
|
|
label.hidden = !piece;
|
|
active = piece ? index : null;
|
|
side = piece ? pieceZone(design, piece).side : null;
|
|
axis = piece ? pieceMoveAxis(design, piece) : "x";
|
|
const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0;
|
|
label.textContent = piece
|
|
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
|
|
: "";
|
|
const open = !!(piece && (axis === "depth" ? berm : shift));
|
|
shell.root.classList.toggle("is-hidden", !open);
|
|
// 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단)
|
|
move.row.classList.toggle("is-hidden", axis !== "x");
|
|
move.controls.classList.toggle("is-hidden", axis !== "x");
|
|
lift.row.classList.toggle("is-hidden", axis !== "depth");
|
|
lift.controls.classList.toggle("is-hidden", axis !== "depth");
|
|
const sideName = side === "left" ? "(좌)" : "(우)";
|
|
shell.title.textContent =
|
|
axis === "y"
|
|
? "노면 높이"
|
|
: axis === "depth"
|
|
? `소단 높이 ${sideName}`
|
|
: `사면 이동 ${sideName}`;
|
|
readout.textContent =
|
|
axis === "y"
|
|
? "방향키 ▲▼ = 종단 계획고(종단도 함께)"
|
|
: axis === "depth" && side
|
|
? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m`
|
|
: `이동 ${extend.toFixed(1)}m`;
|
|
berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2)
|
|
arrows.detach();
|
|
document.removeEventListener("keydown", blockOffAxis);
|
|
if (open) {
|
|
arrows.attach();
|
|
document.addEventListener("keydown", blockOffAxis);
|
|
}
|
|
},
|
|
};
|
|
}
|