feat(B06 횡단): 암 경계선 고름 · 방향키 · 세로 끌기 · 아래 띠 공용 칸 (370-3)

- 새 B06_Section_UI_Cross_Line_Cell.ts — 아래 띠 왼쪽 공용 칸(제목 · ◀▲↺▼▶ 한 벌 · 값 한 줄 말줄임 · 길게 누름 반복) · 숨은 버튼은 방향키 슬롯 뗌
- Design_Pick — 암 점선 위 판정 선(키 rock · ▲▼↺ · ◀▶ 막음 · 방향키 = 암만) · 떠 있는 선 조정창 · 우측 위 치수 라벨 걷음 · 안 고르면 암 칸 · 고르면 그 선(노면 · 사면 · 소단 · 중심점 · 교차점 · 암)
- Center.ts bindAxisDrag(한 축 끌기 · 놓을 때 한 번 · click 한 번 막음) — 중심점 · 암 세로 끌기 같이 씀
- buildRockBoundaryControl = 공용 칸 「안 고름」 꼴(Cross_Design) · Card_Chrome 띠 왼쪽 = 공용 칸 · Detail_Pick 표 강조에 암 판정 선 뺌
- CSS(Cross.css 공용 칸 · Areas.css 암 고름 띠 · Controls.css 주석) · 시험 test_370_3 새로 · 137 · 139_6 · 179_1 · 193_1 · 194_1 · 318 · 356 · 370_2 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-17 12:14:43 +09:00
co-authored by Claude Opus 5.5
parent 6cac1b7cd4
commit fe025b6db2
25 changed files with 930 additions and 307 deletions
@@ -158,17 +158,23 @@ export function appendCardHeader(
card.append(header);
}
/** 하단 정보행 — 중심고와 (암 지반일 때) 암 경계선·암 절토 경사 제어. */
/** 하단 정보행 — 왼쪽 = 선 공용 칸(370-3 · 안 고르면 암 경계선) · 오른쪽 = (암 지반일 때) 암 절토 경사. */
export function appendCardFooter(
card: HTMLElement,
section: CrossSection,
rockBoundary?: RockBoundaryControl,
cutSlope?: CutSlopeControl,
/** 설계선 고름 공용 칸(370-3 · `_Cross_Line_Cell`) — 있으면 왼쪽 칸(안 고르면 암 · 고르면 그 선). */
lineCell?: HTMLElement,
): void {
// 중심고는 그래프 Y축 라벨 상자로 옮김(318-2 · `_Cross_Axes`) — 하단 띠는 암 제어만 · 비어도 띠 높이 그대로
const footer = document.createElement("footer");
// 암 경계선 제어는 하단 정보 행 왼쪽(318-2) — 암 지반만.
if (rockBoundary && section.design?.geometry_preset === "rock") {
if (lineCell) {
lineCell.classList.add("b06-cross-card__rockb");
footer.append(lineCell);
}
// 암 경계선 제어는 하단 정보 행 왼쪽(318-2) — 암 지반만 · 설계선 고름이 없는 카드.
else if (rockBoundary && section.design?.geometry_preset === "rock") {
const rockControl = buildRockBoundaryControl(section, rockBoundary);
rockControl.classList.add("b06-cross-card__rockb");
footer.append(rockControl);
+53 -20
View File
@@ -5,6 +5,7 @@
* · 판정 원 = 중심 십자 자리 · 노면 판정보다 위(`stackPickTargets`) · 다른 고름과 같은 칸(하나만)
* · 고르면 ▲▼ = 종단 계획고(지금 길 · 종단도 듣개) · ◀▶ · 가로 끌기 = 그 측점 노선을 좌우로
* · 끄는 동안 · 키를 잇는 동안은 십자만 옮겨 보이고 · 놓을 때(키는 멈춘 뒤) 한 번 보냄
* · 한 축 끌기(`bindAxisDrag`)는 암 경계선 세로 끌기(370-3)도 씀
* · 좌우 = 노선 편집기가 등록한 host(`setRouteShiftHost` · B05 `_RouteEdit_Shift`) — B06 을 바로 열어
* 편집기가 없으면 종단 보기를 숨긴 채 세움(`setRouteShiftOpener` · 페이지가 닮 · 알림도 페이지 것)
* ========================================================================== */
@@ -107,26 +108,14 @@ export function bindCenterShift(input: {
if (!deltaM) return preview(0);
void shiftRouteAt(chainageM, deltaM).then((moved) => moved || preview(0));
};
hit.addEventListener("pointerdown", (event) => {
const ctm = layer.getScreenCTM();
if (event.button !== 0 || !active() || !ctm) return;
event.preventDefault();
event.stopPropagation(); // 카드 팬으로 안 번짐
const startX = event.clientX;
let deltaM = 0;
const move = (next: PointerEvent): void => {
deltaM = snapStep((next.clientX - startX) / (ctm.a * kx));
preview(deltaM);
};
const up = (): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
send(deltaM);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
bindAxisDrag({
hit,
layer,
axis: "x",
pxPerM: kx,
active,
preview,
send,
});
let pending = 0;
let timer = 0;
@@ -144,6 +133,50 @@ export function bindCenterShift(input: {
};
}
/**
* 고른 판정을 한 축으로 끌기(356-3 중심점 가로 · 370-3 암 경계선 세로) — 고른 뒤에만(안 고른 것을 끌면 카드
* 팬 그대로) · 걸음 0.1 m · 끄는 동안 `preview` · 놓을 때 움직였으면 한 번 `send`(안 움직이면 `preview(0)`) ·
* 놓은 자리 click 은 한 번 막음(같은 것 다시 누름 = 풂으로 안 번짐). `pxPerM` = 그 축 화면 px / m(layer 좌표).
*/
export function bindAxisDrag(input: {
hit: SVGElement;
layer: SVGGraphicsElement;
axis: "x" | "y";
pxPerM: number;
active: () => boolean;
preview: (deltaM: number) => void;
send: (deltaM: number) => void;
}): void {
const { hit, layer, axis, pxPerM, active } = input;
const at = (event: PointerEvent): number => (axis === "x" ? event.clientX : event.clientY);
hit.addEventListener("pointerdown", (event) => {
const ctm = layer.getScreenCTM();
if (event.button !== 0 || !active() || !ctm) return;
event.preventDefault();
event.stopPropagation(); // 카드 팬으로 안 번짐
const start = at(event);
const scale = (axis === "x" ? ctm.a : ctm.d) * pxPerM;
let deltaM = 0;
const move = (next: PointerEvent): void => {
deltaM = snapStep((at(next) - start) / scale);
input.preview(deltaM);
};
const up = (): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
if (!deltaM) return input.preview(0);
const swallow = (click: Event): void => click.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
input.send(deltaM);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
});
}
/** 조정창 읽기 글 — 미리 볼 이동(m · 0 = 안내). */
export function centerPreviewText(deltaM: number): string {
if (!deltaM) return "▲▼ = 종단 계획고 · ◀▶ · 끌기 = 이 측점 노선 좌우";
+18 -100
View File
@@ -28,6 +28,7 @@ export {
} from "./B06_Section_UI_Cross_Design_Surface";
export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line";
import { openCrossDetail, refreshCrossDetail } from "./B06_Section_UI_Cross_Detail";
import { buildLineCell, rockCellState, type RockCellSpec } from "./B06_Section_UI_Cross_Line_Cell";
/** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */
interface ReflowCard {
@@ -205,112 +206,29 @@ function toggle(
return { wrap, button };
}
/**
* 길게 누르는 동안 같은 동작을 반복한다(B05 종단 선 제어 패턴 재사용). 0.5초 유지하면
* 0.1초 간격(0.1m씩) 반복이 시작된다. 버튼이 재빌드로 사라져도 반복이 끊기지 않도록
* 타이머·종료 감지를 버튼이 아니라 window 이벤트로 들고 있는다.
*/
function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } {
let delayTimer = 0;
let repeatTimer = 0;
function stop(): void {
window.clearTimeout(delayTimer);
window.clearInterval(repeatTimer);
delayTimer = 0;
repeatTimer = 0;
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
window.removeEventListener("blur", stop);
}
function start(action: () => void): void {
stop();
delayTimer = window.setTimeout(() => {
repeatTimer = window.setInterval(action, 100);
}, 500);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
window.addEventListener("blur", stop);
}
return { start, stop };
}
/** 암 경계선 상/하/리셋 컨트롤(B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용). E-7: X축 행 배치용 export. */
/** 암 경계선 상/하/리셋 컨트롤 — 아래 띠 공용 칸(370-3 · `_Cross_Line_Cell`)의 「안 고름」 꼴. 설계선 고름이
* 없는 카드(설계 없음)에서 띠 왼쪽 칸으로 씀. */
export function buildRockBoundaryControl(
section: CrossSection,
control: RockBoundaryControl,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b06-design__seg b06-design__rockb";
const legendEl = document.createElement("span");
legendEl.className = "b06-design__seg-legend";
legendEl.textContent = L("B06_Design_RockBoundary_Legend");
wrap.append(legendEl);
const cell = buildLineCell();
cell.show(rockCellState(rockCellSpec(section, control)));
return cell.root;
}
const group = document.createElement("div");
group.className = "b06-design__seg-buttons";
const readout = document.createElement("span");
readout.className = "b06-design__rockb-readout";
const currentOffset = control.offsetFor(section);
readout.textContent = `${currentOffset >= 0 ? "+" : ""}${currentOffset.toFixed(1)}m`;
const repeater = createHoldRepeater();
const makeButton = (
label: string,
title: string,
className: string,
onClick: () => void,
hold = false,
): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = `b06-design__rockb-btn ${className}`;
button.textContent = label;
button.title = hold ? `${title}\n(길게 누르면 0.5초 뒤부터 연속 조정)` : title;
if (hold) {
// 포인터로 누르면 즉시 1회 반응하고 반복을 예약한다. 이어지는 click은 중복이라 삼킨다.
let swallowClick = false;
button.addEventListener("pointerdown", (event) => {
event.stopPropagation();
swallowClick = true;
onClick();
repeater.start(onClick);
});
button.addEventListener("click", (event) => {
event.stopPropagation();
if (swallowClick) {
swallowClick = false;
return;
}
onClick();
});
} else {
button.addEventListener("click", onClick);
}
return button;
/** 암 경계선 칸 제원 — 공용 칸(안 고름 · 암 고름)이 같이 씀. */
export function rockCellSpec(section: CrossSection, control: RockBoundaryControl): RockCellSpec {
return {
legend: L("B06_Design_RockBoundary_Legend"),
offsetM: control.offsetFor(section),
stepM: control.stepM,
upTitle: L("B06_Design_RockBoundary_Up"),
downTitle: L("B06_Design_RockBoundary_Down"),
resetTitle: L("B06_Design_RockBoundary_Reset"),
adjust: (deltaM) => control.adjust(section.chainage_m, deltaM),
reset: () => control.reset(section.chainage_m),
};
group.append(
makeButton(
"▲",
`${L("B06_Design_RockBoundary_Up")} (+${control.stepM}m)`,
"is-up",
() => control.adjust(section.chainage_m, control.stepM),
true,
),
makeButton(
"▼",
`${L("B06_Design_RockBoundary_Down")} (-${control.stepM}m)`,
"is-down",
() => control.adjust(section.chainage_m, -control.stepM),
true,
),
makeButton("↺", L("B06_Design_RockBoundary_Reset"), "is-reset", () =>
control.reset(section.chainage_m),
),
readout,
);
wrap.append(group);
return wrap;
}
/**
+121 -97
View File
@@ -4,16 +4,18 @@
*
* 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을
* 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 두 줄로 적는다.
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 아래 띠 공용 칸(370-3 · `_Cross_Line_Cell`)에
* 제목 · 축에 맞는 버튼 · 치수 한 줄을 적는다(안 고르면 암 경계선 칸 · 떠 있는 선 조정창 · 우측 위 라벨 걷음).
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
* 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 ·
* 고른 조각 쪽 사면을 노견째 ◀ ↺ ▶ · 방향키 · 0.1 m 로 옮김(139-6 쉬프트 · 151-3 ·
* 노폭 안쪽 금지 = 0 에서 멈춤). 축 제약(193-1) — 절 · 성토측 = 그 쪽 바깥 가로만(반대측 고정 ·
* 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고).
* 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬.
* 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2).
* 교차점 손잡이(370-2) — 쪽마다 설계선 ∩ 원지반 원(키 `toe:left|right` · 늘 보임 · ◀▶) · ◀▶ 가 노견 끝 =
* 원지반 자리를 지나면 한 번 섬(`extendStop`) · 사면 키가 절 ↔ 성으로 바뀌면 같은 쪽 손잡이가 이어받음.
* 암 경계선(370-3) — 점선 위 판정 선(키 `rock`) · ▲▼↺ · ◀▶ 막음 · 방향키 = 암만 · 세로 끌기(놓을 때 한 번).
* ========================================================================== */
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
@@ -21,14 +23,20 @@ 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 } from "./B06_Section_UI_Cross_Panel_Base";
import {
arrowsBelongToProfile,
bindArrowKeys,
buildPanelShell,
dpadButton,
makeRow,
} from "./B06_Section_UI_Cross_Panel_Base";
import { bindCenterShift, centerHit, centerPreviewText } from "./B06_Section_UI_Cross_Center";
bindAxisDrag,
bindCenterShift,
centerHit,
centerPreviewText,
} from "./B06_Section_UI_Cross_Center";
import {
buildLineCell,
rockCellState,
rockValueText,
type LineCellState,
type RockCellSpec,
} from "./B06_Section_UI_Cross_Line_Cell";
import { extendStop, toePoints } from "./logic/B06_Section_UI_Cross_Fit";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -36,6 +44,9 @@ const DESIGN_CLASS = "b06-chart__design-cross";
const HIT_CLASS = "b06-chart__design-hit";
/** 교차점 손잡이 원(370-2) — 중심점 판정 원 클래스에 덧붙임(고르기 차례 같음). */
const TOE_CLASS = "b06-chart__toe-hit";
/** 암 경계선 점선 · 그 위 판정 선(370-3 · 판정 선은 보라 선 판정 클래스에 덧붙임). */
const ROCK_LINE_CLASS = "b06-chart__rock-boundary";
export const ROCK_HIT_CLASS = "b06-chart__rock-hit";
/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */
export const DESIGN_LINE_ATTR = "data-design-line";
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
@@ -119,7 +130,7 @@ export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis
isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x";
/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1) ·
* center = 중심점(▲▼ 종단 계획고 · ◀▶ 노선 좌우 · 356-3). */
export type MoveAxis = "x" | "y" | "depth" | "center";
export type MoveAxis = "x" | "y" | "depth" | "center" | "rock";
/** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */
export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number =>
@@ -360,15 +371,20 @@ function tellBermHost(chainageM: number | null): void {
export function designLineMoves(
section: Pick<CrossSection, "station_id" | "chainage_m" | "design" | "samples">,
onDesignChange?: (chainageM: number, change: CrossDesignChange) => void,
/** 암 경계선 칸 제원(`rockCellSpec`) — 암 지반일 때만 씀(370-3). */
rockSpec?: RockCellSpec,
): {
stationId: string;
shift?: ShoulderShift;
berm?: BermDepthControl;
center?: number;
toes?: ReturnType<typeof toePoints>;
rock?: RockCellSpec;
} {
const design = section.design;
if (!onDesignChange || !design) return { stationId: section.station_id };
// 암 경계선 = 암 지반만(370-3 · 띠 칸과 같은 창구)
const rock = design?.geometry_preset === "rock" ? rockSpec : undefined;
if (!onDesignChange || !design) return { stationId: section.station_id, rock };
const send = (change: Partial<CrossDesignChange>) =>
onDesignChange(section.chainage_m, {
...(design as unknown as CrossDesignChange),
@@ -376,6 +392,7 @@ export function designLineMoves(
});
return {
stationId: section.station_id,
rock,
center: section.chainage_m, // 중심점 좌우 = 이 측점 노선(356-3)
shift: {
valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0,
@@ -396,10 +413,8 @@ export function designLineMoves(
export interface DesignLinePick {
pieces: DesignPiece[];
/** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */
label: HTMLElement;
/** 사면 이동 패널(151-3) — 카드가 그림 칸에 붙임 · `shift` 없거나 고름 없으면 숨김. */
panel: HTMLElement;
/** 아래 띠 왼쪽 공용 칸(370-3) — 카드가 띠에 붙임 · 고른 선 제목 · 버튼 · 값 한 줄(안 고르면 암). */
cell: HTMLElement;
setActive: (index: number | null) => void;
}
@@ -423,6 +438,8 @@ export function attachDesignLinePick(input: {
center?: number;
/** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · 늘 보임 · 370-2). */
toes?: ReturnType<typeof toePoints>;
/** 있으면 암 경계선 고름(키 `rock` · ▲▼↺ · 세로 끌기) · 안 고른 때 공용 칸 = 암(370-3). */
rock?: RockCellSpec;
}): DesignLinePick {
const { layer, design, x, toDisplayY } = input;
const x0 = x(0);
@@ -453,10 +470,12 @@ export function attachDesignLinePick(input: {
((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) /
(edges.left.offset_m - edges.right.offset_m);
const toeSides = (["left", "right"] as const).filter((at) => input.toes?.[at]);
const rockLines = input.rock ? kids.filter((el) => el.classList.contains(ROCK_LINE_CLASS)) : [];
const keys = [
...pieceKeys(design, pieces),
...(centerZ === null ? [] : ["center"]),
...toeSides.map((at) => `toe:${at}`),
...(rockLines.length ? ["rock"] : []),
];
if (input.stationId) keysByStation.set(input.stationId, keys);
@@ -484,6 +503,18 @@ export function attachDesignLinePick(input: {
hit.setAttribute("r", "5");
hits.push(hit);
}
// 암 경계선(370-3) — 점선(`appendRockBoundaryOverlay`) 위 판정 선 하나(끊긴 토막도 한 path) · 키 rock
let rockHit: SVGElement | null = null;
if (rockLines.length) {
rockHit = document.createElementNS(SVG_NS, "path");
const path = (el: Element) =>
`M${pointsOf(el)
.map(([px, py]) => `${px},${py}`)
.join(" L")}`;
rockHit.setAttribute("d", rockLines.map(path).join(" "));
rockHit.setAttribute("class", `${HIT_CLASS} ${ROCK_HIT_CLASS}`);
hits.push(rockHit);
}
hits.forEach((hit, index) => {
hit.addEventListener("click", (event) => {
event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음
@@ -492,23 +523,10 @@ export function attachDesignLinePick(input: {
layer.insertBefore(hit, firstStructureHit ?? null);
});
const label = document.createElement("span");
label.className = "b06-cross-card__line-dims";
label.hidden = true;
const cell = buildLineCell();
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 { shift, berm, rock } = input;
const centerShift =
center && input.center !== undefined
? bindCenterShift({
@@ -517,9 +535,25 @@ export function attachDesignLinePick(input: {
chainageM: input.center,
kx,
active: () => axis === "center",
preview: (deltaM) => (readout.textContent = centerPreviewText(deltaM)),
preview: (deltaM) => cell.setValue(centerPreviewText(deltaM)),
})
: null;
// 암 경계선 세로 끌기(370-3) — 고른 뒤에만 · 걸음 0.1 m · 끄는 동안 점선만 옮겨 보이고 놓을 때 한 번
if (rockHit && rock)
bindAxisDrag({
hit: rockHit,
layer: layer as SVGGraphicsElement,
axis: "y",
pxPerM: ky,
active: () => axis === "rock",
preview: (deltaM) => {
for (const el of [rockHit, ...rockLines])
(el as SVGElement).style.setProperty("translate", deltaM ? `0px ${deltaM * ky}px` : "");
const next = rockValueText(Math.min(rock.offsetM + deltaM, 0));
cell.setValue(deltaM ? `${next} — 놓으면 반영` : rockValueText(rock.offsetM));
},
send: (deltaM) => rock.adjust(deltaM),
});
const step = (dir: -1 | 1) => () => {
if (axis === "center") return centerShift?.step(dir);
if (!side || !shift) return;
@@ -533,16 +567,12 @@ export function attachDesignLinePick(input: {
axis === "center"
? `이 측점 노선을 화면 ${screen}으로 0.1 m`
: `고른 사면을 화면 ${screen}으로 0.1 m(노견째 · 노폭 안쪽은 못 감)`;
move.controls.append(
dpadButton("◀", "left", stepTitle("왼쪽"), step(-1)),
dpadButton("↺", "reset", "이 쪽 이동 0", () => {
if (side && shift) shift.set(side, 0);
}),
dpadButton("▶", "right", stepTitle("오른쪽"), step(1)),
);
// 소단 높이 줄(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤)
const lift = makeRow("높이");
lift.controls.classList.add("b06-structure-panel__buttons");
const sideways = (reset: boolean): LineCellState["buttons"] => ({
left: { title: stepTitle("왼쪽"), act: step(-1) },
...(reset && { reset: { title: "이 쪽 이동 0", act: () => side && shift?.set(side, 0) } }),
right: { title: stepTitle("오른쪽"), act: step(1) },
});
// 소단 높이(194-1) — ▲ = 얕게(노면 끝 위 금지 = 0 에서 멈춤) · ▼ = 깊게(사면 끝 앞에서 멈춤)
const toeZ = (at: CrossSide): number =>
Math.min(
...pieces
@@ -557,23 +587,23 @@ export function attachDesignLinePick(input: {
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 lift: LineCellState["buttons"] = {
up: { title: "고른 소단을 0.1 m 위로(노면 끝보다 높을 수 없음)", act: deepen(-1) },
reset: {
title: "이 쪽 소단 = 구간 기본 깊이",
act: () => side && berm?.set(side, design.berm?.depth_m ?? BERM_DEFAULT_DEPTH_M),
},
down: { title: "고른 소단을 0.1 m 아래로", act: deepen(1) },
};
const arrows = bindArrowKeys(cell.root, () => [cell.buttons]);
// 못 가는 축의 방향키는 먹어 막음(193-1) — 사면 ▲▼ 가 종단 계획고로 새지 않고 · 노면 ◀▶ 는 안 감 ·
// 암 ◀▶ 막음(370-3). 노면 ▲▼ 는 그대로 종단도(계획고 · 종단 함께)로 넘김.
const blockOffAxis = (event: KeyboardEvent): void => {
if (!shell.root.isConnected) {
if (!cell.root.isConnected) {
document.removeEventListener("keydown", blockOffAxis);
return;
}
if (shell.root.classList.contains("is-hidden") || arrowsBelongToProfile()) return;
if (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";
@@ -583,61 +613,55 @@ export function attachDesignLinePick(input: {
return {
pieces,
label,
panel: shell.root,
cell: cell.root,
setActive: (index) => {
const key = index === null ? undefined : keys[index];
const onCenter = !!centerShift && key === "center";
const onRock = !!rockHit && key === "rock";
const toeSide = key?.startsWith("toe:") ? (key.slice(4) as CrossSide) : null;
const piece = index === null || index >= pieces.length ? undefined : pieces[index];
const chosen = !!piece || onCenter || !!toeSide;
const chosen = !!piece || onCenter || onRock || !!toeSide;
hits.forEach((hit, i) => hit.classList.toggle("is-active", chosen && i === index));
label.hidden = !chosen;
active = chosen ? index : null;
side = piece ? pieceZone(design, piece).side : toeSide;
axis = onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x";
axis = onRock ? "rock" : onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x";
const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0;
const sideName = side === "left" ? "(좌)" : "(우)";
const moved = extend > 0 ? ` · 이동 +${extendText(extend)}m` : "";
const toe = toeSide ? input.toes?.[toeSide] : null;
label.textContent = onCenter
? "중심점"
: toe
? `교차점 ${sideName} · 노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}`
: piece
? pieceLabel(design, piece) + moved
: "";
const dims = piece ? pieceLabel(design, piece).replace("\n", " · ") : "";
// 아래 띠 공용 칸(370-3) — 안 고르면 암(암 지반만) · 고르면 그 선 제목 · 버튼 · 값 한 줄
const state: LineCellState | null =
onRock || (!chosen && rock)
? rockCellState(rock as RockCellSpec)
: onCenter
? { title: "중심점", value: centerPreviewText(0), buttons: sideways(false) }
: toe
? {
title: `교차점 이동 ${sideName}`,
value: `노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}`,
buttons: shift ? sideways(true) : {},
}
: !piece
? null
: axis === "y"
? { title: "노면 높이", value: `${dims} · ▲▼ = 종단 계획고`, buttons: {} }
: axis === "depth" && side
? {
title: `소단 높이 ${sideName}`,
value: `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m · ${dims}`,
buttons: berm ? lift : {},
}
: {
title: `사면 이동 ${sideName}`,
value: dims + moved,
buttons: shift ? sideways(true) : {},
};
cell.show(state);
const open =
onCenter || (!!toeSide && !!shift) || !!(piece && (axis === "depth" ? berm : shift));
shell.root.classList.toggle("is-hidden", !open);
// 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단)
// 중심점 = 이동 줄 ◀▶(노선 좌우 · ↺ 없음) · ▲▼ 는 종단도로(356-3)
const sideways = axis === "x" || axis === "center";
move.row.classList.toggle("is-hidden", !sideways);
move.controls.classList.toggle("is-hidden", !sideways);
move.controls.children[1]?.classList.toggle("is-hidden", axis === "center");
const [toLeft, , toRight] = move.controls.children as unknown as HTMLElement[];
[toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")];
lift.row.classList.toggle("is-hidden", axis !== "depth");
lift.controls.classList.toggle("is-hidden", axis !== "depth");
shell.title.textContent =
axis === "center"
? "중심점"
: toeSide
? `교차점 이동 ${sideName}`
: axis === "y"
? "노면 높이"
: axis === "depth"
? `소단 높이 ${sideName}`
: `사면 이동 ${sideName}`;
readout.textContent =
axis === "center"
? centerPreviewText(0)
: axis === "y"
? "방향키 ▲▼ = 종단 계획고(종단도 함께)"
: axis === "depth" && side
? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m`
: `이동 ${extendText(extend)}m`;
onCenter ||
onRock ||
(!!toeSide && !!shift) ||
!!(piece && (axis === "depth" ? berm : shift));
berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2)
arrows.detach();
document.removeEventListener("keydown", blockOffAxis);
@@ -53,15 +53,17 @@ function targetsOf(svg: Element, design: CrossDesign | undefined, key: string):
const ppm = Number(svg.getAttribute("data-px-per-m"));
if (!design || !center || !(ppm > 0)) return [];
const x0 = Number(center.getAttribute("x1"));
return [...svg.querySelectorAll(".b06-chart__design-hit")].filter((hit) => {
const xs = (hit.getAttribute("points") ?? "")
.trim()
.split(/\s+/)
.map((pair) => Number(pair.split(",")[0]));
const mid = (xs[0] + xs[xs.length - 1]) / 2;
// x = 왼쪽 여백 + (최대 offset − offset) × ppm → offset = (x(0) − x) ÷ ppm
return lineZone(design, (x0 - mid) / ppm) === target.zone;
});
return [...svg.querySelectorAll(".b06-chart__design-hit:not(.b06-chart__rock-hit)")].filter(
(hit) => {
const xs = (hit.getAttribute("points") ?? "")
.trim()
.split(/\s+/)
.map((pair) => Number(pair.split(",")[0]));
const mid = (xs[0] + xs[xs.length - 1]) / 2;
// x = 왼쪽 여백 + (최대 offset − offset) × ppm → offset = (x(0) − x) ÷ ppm
return lineZone(design, (x0 - mid) / ppm) === target.zone;
},
);
}
/** 그림 복제 안 강조 — 묻어 온 `.is-active` 를 지우고 `key` 대상만 켬(null = 모두 끔). */
@@ -0,0 +1,172 @@
/* =============================================================================
* B06_Section_UI_Cross_Line_Cell.ts
* 횡단 카드 아래 띠 왼쪽 **공용 칸**(370-3) — 고른 선의 제목 · 축에 맞는 버튼 · 값 한 줄.
*
* · 안 고르면 암 경계선 ▲▼↺ · 값(암 지반만 · 토사는 빈 칸 · 띠 높이 그대로)
* · 고르면 그 선(노면 · 사면 · 소단 · 중심점 · 교차점 · 암) — `_Cross_Design_Pick` 이 상태를 넣음
* · 버튼 다섯 칸(◀ ▲ ↺ ▼ ▶)은 한 벌 · 상태마다 보임 · 툴팁 · 동작만 바꿈 · 방향키는 보이는 슬롯만 찾음
* · ▲▼ 길게 누름 = 0.5 초 뒤 0.1 초마다 반복(암 · B05 종단 선 제어 꼴)
* ========================================================================== */
export type CellSlot = "left" | "up" | "reset" | "down" | "right";
const SLOTS: Array<[CellSlot, string]> = [
["left", "◀"],
["up", "▲"],
["reset", "↺"],
["down", "▼"],
["right", "▶"],
];
export interface CellButton {
title: string;
act: () => void;
/** 길게 누르면 반복. */
hold?: boolean;
}
export interface LineCellState {
title: string;
buttons: Partial<Record<CellSlot, CellButton>>;
value: string;
}
export interface LineCell {
root: HTMLElement;
/** 버튼 줄 — 방향키 묶음(`bindArrowKeys`)이 슬롯 버튼을 찾는 그릇. */
buttons: HTMLElement;
/** null = 빈 칸(자리는 남음). */
show: (state: LineCellState | null) => void;
/** 값 글만 바꿈(끄는 동안 미리 보기). */
setValue: (text: string) => void;
}
/**
* 길게 누르는 동안 같은 동작을 반복한다(B05 종단 선 제어 패턴 재사용). 0.5초 유지하면
* 0.1초 간격 반복이 시작된다. 버튼이 재빌드로 사라져도 반복이 끊기지 않도록
* 타이머·종료 감지를 버튼이 아니라 window 이벤트로 들고 있는다.
*/
function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } {
let delayTimer = 0;
let repeatTimer = 0;
function stop(): void {
window.clearTimeout(delayTimer);
window.clearInterval(repeatTimer);
delayTimer = 0;
repeatTimer = 0;
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
window.removeEventListener("blur", stop);
}
function start(action: () => void): void {
stop();
delayTimer = window.setTimeout(() => {
repeatTimer = window.setInterval(action, 100);
}, 500);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
window.addEventListener("blur", stop);
}
return { start, stop };
}
/** 버튼 칸 표시(보임과 무관 · 시험이 찾음) — 슬롯 클래스(`b06-structure-panel__btn--*`)는 보일 때만. */
export const CELL_BTN = "b06-cross-card__cell-btn--";
const HOLD_HINT = "\n(길게 누르면 0.5초 뒤부터 연속 조정)";
export function buildLineCell(): LineCell {
const root = document.createElement("div");
root.className = "b06-design__seg b06-design__rockb b06-cross-card__linecell";
root.addEventListener("click", (event) => event.stopPropagation()); // 카드 고름 · 팬으로 안 번짐
const legend = document.createElement("span");
legend.className = "b06-design__seg-legend";
const group = document.createElement("div");
group.className = "b06-design__seg-buttons";
const readout = document.createElement("span");
readout.className = "b06-design__rockb-readout";
const repeater = createHoldRepeater();
let current: LineCellState | null = null;
const run = (slot: CellSlot) => current?.buttons[slot]?.act();
const buttons = SLOTS.map(([slot, text]) => {
const button = document.createElement("button");
button.type = "button";
button.className = `b06-design__rockb-btn ${CELL_BTN}${slot}`;
if (slot === "reset") button.classList.add("is-reset");
button.textContent = text;
// 길게 누름 — 누르면 즉시 1회 · 반복 예약 · 이어지는 click 은 삼킴
let swallowClick = false;
button.addEventListener("pointerdown", (event) => {
if (!current?.buttons[slot]?.hold) return;
event.stopPropagation();
swallowClick = true;
run(slot);
repeater.start(() => run(slot));
});
button.addEventListener("click", (event) => {
event.stopPropagation();
if (swallowClick) {
swallowClick = false;
return;
}
run(slot);
});
group.append(button);
return [slot, button] as const;
});
group.append(readout);
root.append(legend, group);
const cell: LineCell = {
root,
buttons: group,
show: (state) => {
current = state;
root.classList.toggle("is-empty", !state);
legend.textContent = state?.title ?? "";
readout.textContent = state?.value ?? "";
readout.title = state?.value ?? "";
for (const [slot, button] of buttons) {
const spec = state?.buttons[slot];
button.hidden = !spec;
// 방향키 묶음은 슬롯 클래스로 찾음 — 숨은 버튼은 슬롯을 떼어 키를 안 삼킴(노면 ▲▼ = 종단도)
button.classList.toggle(`b06-structure-panel__btn--${slot}`, !!spec);
button.title = spec ? spec.title + (spec.hold ? HOLD_HINT : "") : "";
}
},
setValue: (text) => {
readout.textContent = text;
readout.title = text;
},
};
cell.show(null);
return cell;
}
/** 암 경계선 칸 상태(안 고름 · 암 고름 둘 다) — ▲ = 위로 · ▼ = 아래로 · ↺ = 기본. */
export interface RockCellSpec {
legend: string;
offsetM: number;
stepM: number;
upTitle: string;
downTitle: string;
resetTitle: string;
adjust: (deltaM: number) => void;
reset: () => void;
}
export const rockValueText = (offsetM: number): string =>
`${offsetM >= 0 ? "+" : ""}${offsetM.toFixed(1)}m`;
export function rockCellState(rock: RockCellSpec): LineCellState {
return {
title: rock.legend,
value: rockValueText(rock.offsetM),
buttons: {
up: {
title: `${rock.upTitle} (+${rock.stepM}m)`,
act: () => rock.adjust(rock.stepM),
hold: true,
},
down: {
title: `${rock.downTitle} (-${rock.stepM}m)`,
act: () => rock.adjust(-rock.stepM),
hold: true,
},
reset: { title: rock.resetTitle, act: rock.reset },
},
};
}
+9 -6
View File
@@ -32,6 +32,7 @@ import {
appendCrossDesignOverlay,
appendPavementOverlay,
appendRockBoundaryOverlay,
rockCellSpec,
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome";
@@ -202,6 +203,7 @@ export function createCrossSectionCard(
const culvertState = emptyCardCulvertState();
/** 보라 선 고름(137-2) — 고름은 페이지 한 칸(`_Cross_Design_Pick`) · 강조는 그린 뒤 붙음. */
let setLineActive: (index: number | null) => void = () => undefined;
let linePick: DesignLinePick | null = null;
const dropLine = (): void => {
setPickedLine(section.station_id, null);
setLineActive(null);
@@ -413,7 +415,6 @@ export function createCrossSectionCard(
const toDisplayY = (elevation: number): number =>
y(elevationMid + (elevation - elevationMid) * exaggeration);
appendGroundProfile(plotLayer, drawSamples, x, toDisplayY);
let linePick: DesignLinePick | null = null;
if (section.design) {
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
@@ -607,8 +608,12 @@ export function createCrossSectionCard(
x,
toDisplayY,
onPick: pickLine,
// 고른 선 쪽 노견 확장(139-6) · 소단 높이(194-1) · 조각 키(194-2) — 카드 버튼과 같은 창구
...designLineMoves(section, onDesignChange),
// 고른 선 쪽 노견 확장(139-6) · 소단 높이(194-1) · 조각 키(194-2) · 암(370-3) — 카드 버튼과 같은 창구
...designLineMoves(
section,
onDesignChange,
rockBoundary && rockCellSpec(section, rockBoundary),
),
});
setLineActive = linePick.setActive;
setLineActive(pickedLineAt(section.station_id));
@@ -679,9 +684,7 @@ export function createCrossSectionCard(
fit: () => stationWidth.request(section.chainage_m, crossHalfWidth ?? maxOffset),
};
const zoomBar = buildZoomControls(zoomPan, widthActions);
if (linePick) zoomBar.prepend(linePick.label); // 치수 = [+ − ⤢] 왼쪽(137-2)
chartWrap.append(svg, readout.root, zoomBar, panel.root);
if (linePick) chartWrap.append(linePick.panel); // 사면 이동 패널(151-3)
for (const panel of bodies.panels) chartWrap.append(panel.root);
if (activeArea) {
setBandActive(activeArea);
@@ -690,6 +693,6 @@ export function createCrossSectionCard(
card.append(chartWrap);
}
appendCardFooter(card, section, rockBoundary, cutSlope);
appendCardFooter(card, section, rockBoundary, cutSlope, linePick?.cell); // 왼쪽 = 선 공용 칸(370-3)
return card;
}
@@ -634,6 +634,38 @@ html.is-bm-route .b06-section__notice-bar {
padding: 1px 4px;
}
/* 선 공용 칸(370-3) — 고른 선 제목 · 버튼 · 값 한 줄(넘치면 말줄임) · 빈 칸도 자리(띠 높이) 그대로. */
.b06-cross-card__linecell {
flex: 0 1 auto;
min-width: 0;
}
.b06-cross-card__linecell.is-empty {
visibility: hidden;
}
.b06-cross-card__linecell .b06-design__seg-buttons {
min-width: 0;
}
.b06-cross-card__linecell .b06-design__rockb-readout {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.b06-cross-card__linecell .b06-design__rockb-btn {
flex: 0 0 auto;
border-left: none;
}
.b06-cross-card__linecell
.b06-design__rockb-btn:not([hidden])
~ .b06-design__rockb-btn:not([hidden]) {
border-left: 1px solid var(--color-border);
}
/* 측점별 암 절토 경사(2026-09-07) — 행의 **오른쪽 끝**(암 경계 = 왼쪽). */
.b06-cross-card__cutslope {
margin-left: auto;
@@ -205,6 +205,12 @@
cursor: pointer;
}
/* 암 경계선 고름(370-3) — 점선 위 투명 판정 선 · 고르면 경고색 띠 · 세로 끌기. */
.b06-chart__design-hit.b06-chart__rock-hit.is-active {
stroke: color-mix(in srgb, var(--color-warning) 40%, transparent);
cursor: ns-resize;
}
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
.b06-chart__carriageway-tick {
stroke: var(--color-royal-amethyst);
@@ -223,7 +223,7 @@
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 경사비율 / 수평 · 높이). */
/* 고른 선 치수(137-2) — 상세 모달 줌 막대 왼쪽(179-1) · 두 줄(151-1) · 카드는 아래 띠 공용 칸(370-3). */
.b06-cross-card__line-dims {
align-self: center;
padding: 1px var(--spacing-4);