Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-12 22:03:16 +09:00
15 changed files with 609 additions and 24 deletions
+6 -2
View File
@@ -63,6 +63,8 @@ export interface SvgZoomPanOptions {
initial?: ZoomPanState;
/** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */
homeScale?: number;
/** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */
home?: ZoomPanState;
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
onChange?: (state: ZoomPanState) => void;
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
@@ -88,11 +90,13 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
};
const maxScale = options.maxScale ?? 8;
const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale);
const home: ZoomPanState = {
const home: ZoomPanState = options.home ?? {
scale: homeScale,
tx: (plot.x + plot.width / 2) * (1 - homeScale),
ty: (plot.y + plot.height / 2) * (1 - homeScale),
};
/** 축소 한계 — 1배 · 처음 보기가 더 작으면 그만큼. */
const minScale = Math.min(1, home.scale);
let scale = initial?.scale ?? home.scale;
let tx = initial?.tx ?? home.tx;
let ty = initial?.ty ?? home.ty;
@@ -132,7 +136,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
const zoomAt = (factor: number, cx: number, cy: number): void => {
const next = Math.min(maxScale, Math.max(1, scale * factor));
const next = Math.min(maxScale, Math.max(minScale, scale * factor));
tx = cx - ((cx - tx) / scale) * next;
ty = cy - ((cy - ty) / scale) * next;
scale = next;
@@ -419,7 +419,7 @@ export function attachDesignLinePick(input: {
const pieces = visiblePieces(mergeStraightPieces(runs), occluders);
if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces));
// 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김.
// 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1).
const firstStructureHit = kids.find((el) =>
el.classList.contains("b06-chart__culvert-revet-hit"),
);
+13 -1
View File
@@ -37,6 +37,7 @@ import {
type ZoomPanState,
} from "../A00_Common/b_svg_zoom_pan";
import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import { detailHomeView, layerPoints } from "./B06_Section_UI_Cross_Detail_Home";
import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table";
import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
import {
@@ -194,7 +195,7 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 ·
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) ·
* 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */
* 처음 · 원복 = 중심점이 칸 가운데 · 그림 전체(가로 = 카드 반폭 266-1 · 세로 = 그 안 그림)(295-2). */
export function attachDetailZoom(
clone: SVGSVGElement,
axes: CrossAxesOptions | null,
@@ -220,6 +221,16 @@ export function attachDetailZoom(
);
};
follow(initial ?? IDENTITY_VIEW);
const plot = {
x: CROSS_PAD.left,
y: CROSS_PAD.top,
width: width - CROSS_PAD.left - CROSS_PAD.right,
height: height - CROSS_PAD.top - CROSS_PAD.bottom,
};
const home =
markerLine && Number.isFinite(cx) && Number.isFinite(cy)
? detailHomeView(layerPoints(layer), [cx, cy], plot, homeScale, 16)
: undefined;
return attachSvgZoomPan({
svg: clone,
layer,
@@ -232,6 +243,7 @@ export function attachDetailZoom(
wheelZoom: true,
initial,
homeScale,
home,
onChange: (state) => {
follow(state);
onChange(state);
@@ -16,6 +16,8 @@ export type LineKind = CrossPickPiece["line"];
export interface SplitPiece {
line: LineKind;
points: Pt[];
/** 구조물 선 하나(295-3) — 나눈 조각이 같은 번호면 고를 때 한 묶음(닫힌 영역 · 저장은 조각대로). */
group?: number;
}
/** 서버 `LINE_TOLERANCE_M` 과 같은 값 — 두 끝이 선에서 이만큼 안이면 「선 위」 · 고리 끝 맞음. */
@@ -221,7 +223,12 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] {
const [c0, c1] = [marks[k], marks[k + 1]];
const inner = pts.slice(Math.floor(c0) + 1, Math.ceil(c1));
const points = [at(c0), ...inner, at(c1)];
if (pathLength(points) >= MIN_PIECE_M) out.push({ line: line.line, points });
if (pathLength(points) >= MIN_PIECE_M)
out.push({
line: line.line,
points,
...(line.group !== undefined && { group: line.group }),
});
}
});
return out;
@@ -0,0 +1,49 @@
/* =============================================================================
* B06_Section_UI_Cross_Detail_Home.ts
* [상세] 모달 첫 보기(295-2) — 중심점(중심 십자)이 칸 가운데 · 그림 전체가 들어가게
* 축마다 중심에서 큰 쪽 반폭으로. [⤢] · 더블클릭 원복도 같은 보기. 카드 보기는 그대로.
*
* 가로 반폭 = 카드 반폭(266-1 `homeScale` 이 되좁히던 폭)의 큰 쪽 · 세로 반폭 = 그 가로 안에
* 그려진 꼭짓점이 중심에서 위 · 아래로 먼 쪽.
* ========================================================================== */
import type { ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
type Pt = [number, number];
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
/** 첫 보기 배율 · 이동 — 중심 `center` 를 `plot` 가운데에. `homeScale` = 1배 대비 카드 반폭 배율. */
export function detailHomeView(
points: Pt[],
center: Pt,
plot: Rect,
homeScale: number,
maxScale: number,
): ZoomPanState {
const [cx, cy] = center;
const halfX = Math.max(cx - plot.x, plot.x + plot.width - cx) / Math.max(homeScale, 1e-6);
const ys = points.filter(([x]) => Math.abs(x - cx) <= halfX).map(([, y]) => y);
const halfY = Math.max(1, ...ys.map((y) => Math.abs(y - cy)));
const scale = Math.min(plot.width / 2 / halfX, plot.height / 2 / halfY, maxScale);
return {
scale,
tx: plot.x + plot.width / 2 - scale * cx,
ty: plot.y + plot.height / 2 - scale * cy,
};
}
/** 그림 층의 꺾은선 · 다각형 꼭짓점(그림 px). */
export function layerPoints(layer: Element): Pt[] {
return [...layer.querySelectorAll("polyline, polygon")].flatMap((el) =>
(el.getAttribute("points") ?? "")
.trim()
.split(/\s+/)
.map((pair) => pair.split(",").map(Number))
.filter((p): p is Pt => p.length === 2 && p.every(Number.isFinite)),
);
}
@@ -36,6 +36,8 @@ import {
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";
@@ -100,12 +102,14 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli
else if (el.classList.contains("b06-chart__rock-boundary"))
others.push({ line: "rock", points });
}
const straight = mergeStraightPieces(runs).map((piece): SplitPiece => ({
// 구조물 선(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]);
}
@@ -117,6 +121,24 @@ export function toggleSelection(selected: number[], index: number, additive: boo
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: "구조물 선",
@@ -128,6 +150,10 @@ const LINE_NAME: Record<SplitPiece["line"], string> = {
/** 고른 것 값 두 줄 — 하나 = 길이 · 경사비율 / 수평 · 높이(카드 꼴) · 여럿 = 길이 합 / 넓이. */
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];
@@ -152,8 +178,9 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie
}
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 (
`${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` +
`${units}개 · 길이 합 ${total.toFixed(2)}m\n` +
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`)
);
}
@@ -236,6 +263,8 @@ const inside = (p: Pt, ring: Pt[]): boolean =>
* 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이.
* 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다
* 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역.
* 고르기 차례(295-1 · 카드와 같은 잣대) = 선 > 작은 면 > 큰 면 · 띠 — `bandArea`(그 자리 면적 띠
* 넓이 · 그림 px²)가 영역보다 작을 때만 띠(표가 맡음).
*/
export function pickAt(
point: Pt,
@@ -244,7 +273,8 @@ export function pickAt(
rings: Pt[][],
areas: number[],
pieces: SplitPiece[],
): { piece: number } | { face: number } | null {
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[] => [
@@ -268,9 +298,17 @@ export function pickAt(
(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(";")}` : "";
@@ -283,8 +321,8 @@ export function attachDetailSelect(input: {
picked: (additive: boolean) => void;
/** [칸에 넣기] 단추. */
commit: () => void;
/** 면적 고르기 중 그 자리에 면적 띠가 있나 — 있으면 영역 대신 띠(표가 맡음). */
band?: (event: MouseEvent) => boolean;
/** 면적 고르기 중 그 자리 면적 띠 넓이(그림 px²) · 없음 = null — 영역보다 작을 때만 띠(295-1). */
band?: (event: MouseEvent) => number | null;
}): DetailSelect {
const { cad } = input;
let pieces: SplitPiece[] = [];
@@ -343,7 +381,8 @@ export function attachDetailSelect(input: {
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);
return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces);
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 => {
@@ -352,9 +391,10 @@ export function attachDetailSelect(input: {
const additive = shift || mode === "area";
noteText = null;
if (!pick || !("piece" in pick)) {
if (pick && !(mode === "area" && input.band?.(event))) {
// 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter)
event.stopPropagation();
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]));
@@ -369,7 +409,7 @@ export function attachDetailSelect(input: {
// 선이 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬
event.stopImmediatePropagation();
chosenFaces = [];
selected = toggleSelection(selected, pick.piece, additive);
selected = toggleMembers(selected, groupMembers(pieces, pick.piece), additive);
show();
input.picked(additive);
};
@@ -403,6 +443,19 @@ export function attachDetailSelect(input: {
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) {
@@ -25,6 +25,7 @@ import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom";
import { attachDetailSelect } from "./B06_Section_UI_Cross_Detail_Select";
import { pointsArea } from "./B06_Section_UI_Cross_Pick_Order";
/** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */
export const MAX_ROWS = 12;
@@ -387,13 +388,16 @@ export function attachDetailTable(host: DetailTableHost): {
if (picking?.kind === "length" && !additive) commitSelection();
},
commit: commitSelection,
band: (event) => !!bandAt(event),
band: (event) => {
const hit = bandAt(event);
return hit ? pointsArea(hit.getAttribute("points")) : null;
},
});
const bandAt = (event: MouseEvent): Element | undefined =>
document
.elementsFromPoint(event.clientX, event.clientY)
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 누름은 위가 막음(261-3 거리 판정)
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4) — 선 · 띠보다 좁은 영역은 위가 막음(261-3 거리 판정 · 295-1)
const onPick = (event: MouseEvent): void => {
if (picking?.kind !== "area") return;
const hit = bandAt(event);
@@ -0,0 +1,58 @@
/* =============================================================================
* B06_Section_UI_Cross_Pick_Order.ts
* 횡단 카드 고르기 차례(295-1) — 고를 자리가 좁은 것이 위(모달 `pickAt` 과 같은 잣대).
*
* 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) ·
* 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로.
* 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님.
* ========================================================================== */
/** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */
const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"];
/** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */
const LINE_HIT_CLASS = "b06-chart__design-hit";
/** 끌기로 볼 움직임(px) — 형상 고름(`SHAPE_DRAG_PX`)과 같은 값. */
const DRAG_PX = 3;
/** `points` 속성 다각형 넓이(px²) — 점이 셋 못 되면 0. */
export function pointsArea(points: string | null): number {
const pts = (points ?? "")
.trim()
.split(/\s+/)
.map((pair) => pair.split(",").map(Number))
.filter((p) => p.length === 2 && p.every(Number.isFinite));
if (pts.length < 3) return 0;
let twice = 0;
pts.forEach(([x0, y0], i) => {
const [x1, y1] = pts[(i + 1) % pts.length];
twice += x0 * y1 - x1 * y0;
});
return Math.abs(twice) / 2;
}
/** 판정면을 넓이 차례로 다시 세우고 보라 선 판정을 맨 위로 · 끌기 누름을 막음. */
export function stackPickTargets(layer: SVGElement): void {
const kids = [...layer.children];
const surfaces = kids
.filter((el) => SURFACE_HIT_CLASSES.some((name) => el.classList.contains(name)))
.map((el) => ({ el, area: pointsArea(el.getAttribute("points")) }))
.sort((a, b) => b.area - a.area)
.map(({ el }) => el);
const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS));
layer.append(...surfaces, ...lines);
let down: { x: number; y: number } | null = null;
layer.addEventListener(
"pointerdown",
(event) => (down = { x: (event as PointerEvent).clientX, y: (event as PointerEvent).clientY }),
true,
);
layer.addEventListener(
"click",
(event) => {
const { clientX, clientY } = event as MouseEvent;
if (down && Math.hypot(clientX - down.x, clientY - down.y) > DRAG_PX) event.stopPropagation();
down = null;
},
true,
);
}
+2
View File
@@ -22,6 +22,7 @@ import {
} from "./B06_Section_UI_Cross_Ford_Pavement";
import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { attachShapePick, shapePickRoute } from "./B06_Section_UI_Cross_Shape_Pick";
import { stackPickTargets } from "./B06_Section_UI_Cross_Pick_Order";
import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain";
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
import { attachChuteBandPick, groundLine } from "./B06_Section_UI_Cross_Wall_Hatch";
@@ -609,6 +610,7 @@ export function createCrossSectionCard(
});
setLineActive = linePick.setActive;
setLineActive(pickedLineAt(section.station_id));
stackPickTargets(plotLayer); // 고르기 차례 = 좁은 것 위 · 보라 선 맨 위(295-1)
}
// 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한).
@@ -196,7 +196,8 @@
}
/* 선 조각 판정 선(209-1) — 투명 굵은 선 · 배율 무관 굵기 · 고른 조각 = 붉은 띠 */
.b06-detail__piece {
.b06-detail__piece,
.b06-detail__piece-group {
fill: none;
stroke: transparent;
/* 누름 판정은 거리(LINE_PICK_PX 4) — 굵기는 걸침 표시만 */
@@ -207,7 +208,9 @@
cursor: pointer;
}
.b06-detail__piece:hover {
/* 구조물 선 묶음(295-3) — 나뉜 조각 위 한 줄 · 걸치면 선 하나로 비침 */
.b06-detail__piece:hover,
.b06-detail__piece-group:hover {
stroke: color-mix(in srgb, var(--color-accent) 25%, transparent);
}
+4 -2
View File
@@ -92,6 +92,8 @@ function el(tag) {
};
return walk(self);
},
// 첫 보기 꼭짓점(295-2) — 이 그림엔 꺾은선 · 다각형 없음
querySelectorAll: () => [],
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
fire: (type, event) => {
const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event };
@@ -109,7 +111,7 @@ globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: e
const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts"));
// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150)
// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 319,142 = 칸 가운데 · 295-2 첫 보기 = 1배)
const AXES = {
widthPx: 600,
heightPx: 300,
@@ -126,7 +128,7 @@ function chart() {
const clipGroup = svgElement("g", { "clip-path": "url(#c)" });
const plot = svgElement("g", { class: "b06-chart__plot-layer" });
const center = svgElement("g", { class: "b06-chart__center" });
center.append(svgElement("line", { x1: 300, y1: 132, x2: 300, y2: 168 }));
center.append(svgElement("line", { x1: 319, y1: 124, x2: 319, y2: 160 }));
plot.append(center);
clipGroup.append(plot);
svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" }));
@@ -0,0 +1,273 @@
/* PLAN 295 단위검증 헬퍼 — 횡단 카드 고르기 차례 · [상세] 모달 고르기 차례 · 첫 보기 · 구조물 선 안 나눔.
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
* 짝 = `test_295_pick_order_detail.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const B06 = path.join(ROOT, "B06_Section");
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
globalThis.window = { sessionStorage: new Map(), localStorage: new Map() };
globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} };
const STUBS = {
"@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) },
"./B06_Section_UI_Section_Common": { CROSS_PAD },
};
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
function loadTs(full) {
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith("@util/"))
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const P = loadTs(path.join(B06, "B06_Section_UI_Cross_Pick_Order.ts"));
const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts"));
const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts"));
const H = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Home.ts"));
const out = {};
/* ── 카드: 흉내 층 ───────────────────────────────────────────────────── */
function node(name, classes, points) {
return {
name,
parent: null,
tagName: points && classes.includes("b06-chart__design-hit") ? "polyline" : "polygon",
classList: { contains: (c) => classes.includes(c) },
getAttribute: (a) => (a === "points" ? points : null),
};
}
function layer(kids) {
const listeners = [];
const self = {
children: [],
append: (...nodes) =>
nodes.forEach((n) => {
const at = self.children.indexOf(n);
if (at >= 0) self.children.splice(at, 1);
self.children.push(n);
}),
addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture }),
fire: (type, event) => {
let stopped = false;
const full = { ...event, stopPropagation: () => (stopped = true) };
listeners.filter((l) => l.type === type && l.capture).forEach((l) => l.fn(full));
return stopped;
},
};
self.append(...kids);
return self;
}
const box = (x0, y0, x1, y1) => `${x0},${y0} ${x1},${y0} ${x1},${y1} ${x0},${y1}`;
const HIT = "b06-chart__culvert-revet-hit";
// 꽂힌 옛 차례 — 면적 띠(맨 아래) · 보라 선(구조물 판정면 앞) · 형상 · 도수로 띠 · 벽 · 관
const kids = [
node("band", ["b06-chart__area"], box(0, 0, 600, 300)),
node("slope", ["b06-chart__design-hit"], "400,100 500,200"),
node("top", ["b06-chart__design-hit"], "300,100 400,100"),
node("shape", ["b06-chart__shape-hit"], box(300, 100, 310, 110)), // 100
node("chute", [HIT, "b06-chart__chute-hit"], box(390, 90, 510, 210)), // 14400 — 성토 사면을 덮음
node("wall", [HIT], box(200, 100, 220, 300)), // 4000
node("pipe", [HIT], box(250, 250, 255, 255)), // 25
];
const L = layer(kids);
P.stackPickTargets(L);
out.cardOrder = L.children.map((n) => n.name);
// 누른 자리에서 맨 위 판정(뒤에서부터) — 다각형 안 · 선은 5 px 안
const inside = (p, pts) => {
let inn = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const [ax, ay] = pts[i];
const [bx, by] = pts[j];
if (ay > p[1] !== by > p[1] && p[0] < ((bx - ax) * (p[1] - ay)) / (by - ay) + ax) inn = !inn;
}
return inn;
};
const gap = (p, [a, b]) => {
const d = [b[0] - a[0], b[1] - a[1]];
const t = Math.max(
0,
Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / (d[0] ** 2 + d[1] ** 2)),
);
return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]);
};
const ptsOf = (n) =>
n
.getAttribute("points")
.split(" ")
.map((q) => q.split(",").map(Number));
const topAt = (p, order) =>
[...order]
.reverse()
.find((n) =>
n.classList.contains("b06-chart__design-hit") ? gap(p, ptsOf(n)) <= 5 : inside(p, ptsOf(n)),
)?.name;
out.slopeClick = topAt([450, 150], L.children); // 띠 안 성토 사면 위
out.slopeClickOld = topAt([450, 150], kids); // 옛 차례
out.chuteClick = topAt([480, 120], L.children); // 띠 안 · 선 밖
out.pipeOverWall = topAt([252, 252], [...L.children]);
out.area = P.pointsArea(box(0, 0, 10, 20));
// 끌기 = 고름 아님
L.fire("pointerdown", { clientX: 10, clientY: 10 });
out.dragStopped = L.fire("click", { clientX: 20, clientY: 10 });
L.fire("pointerdown", { clientX: 10, clientY: 10 });
out.clickStopped = L.fire("click", { clientX: 11, clientY: 11 });
/* ── 모달: 고르기 차례 ───────────────────────────────────────────────── */
const sq = (x0, y0, x1, y1) => [
[x0, y0],
[x1, y0],
[x1, y1],
[x0, y1],
];
const big = sq(0, 0, 100, 100);
const small = sq(40, 40, 60, 60);
const edges = (ring) => ring.map((p, i) => [p, ring[(i + 1) % ring.length]]);
const paths = [...edges(big), ...edges(small)];
const pieces = paths.map((points) => ({ line: "design", points }));
const rings = [big, small];
const areas = [10000, 400];
const pick = (p, band = null) => S.pickAt(p, 1, paths, rings, areas, pieces, band);
out.modal = {
line: pick([50, 41]), // 작은 면 둘레 1 px
small: pick([50, 50]),
big: pick([20, 20]),
bandSmaller: pick([50, 50], 100),
bandBigger: pick([50, 50], 5000),
bandBigFace: pick([20, 20], 5000),
bandOnly: S.pickAt([500, 500], 1, paths, rings, areas, pieces, 50),
lineOverBand: pick([50, 41], 1),
};
/* ── 모달: 첫 보기 ───────────────────────────────────────────────────── */
const plot = { x: 50, y: 10, width: 500, height: 280 };
const center = [300, 150];
const drawn = [
[100, 120],
[500, 140],
[300, 360], // 아래로 210 — 큰 쪽
[200, 100],
[5000, 9000], // 가로 창 밖 = 셈 안 함
];
const home = H.detailHomeView(drawn, center, plot, 1, 16);
const map = ([x, y]) => [home.tx + home.scale * x, home.ty + home.scale * y];
out.home = {
scale: home.scale,
center: map(center),
plotCenter: [plot.x + plot.width / 2, plot.y + plot.height / 2],
fits: drawn.slice(0, 4).every(([x, y]) => {
const [sx, sy] = map([x, y]);
return (
sx >= plot.x - 1e-6 &&
sx <= plot.x + plot.width + 1e-6 &&
sy >= plot.y - 1e-6 &&
sy <= plot.y + plot.height + 1e-6
);
}),
flat: H.detailHomeView([[300, 150]], center, plot, 2, 16).scale,
};
/* ── 모달: 구조물 선 안 나눔 ─────────────────────────────────────────── */
// 상자(구조물 네 변 · 변마다 묶음) + 가로지르는 원지반선
const boxLines = edges(sq(0, 0, 10, 10)).map((points, group) => ({
line: "structure",
points,
group,
}));
const split = G.splitAtCrossings([
...boxLines,
{
line: "ground",
points: [
[-5, 5],
[15, 5],
],
},
]);
const left = split.findIndex(
(p) => p.line === "structure" && p.points.every(([x]) => Math.abs(x) < 1e-9),
);
const members = S.groupMembers(split, left);
const chosen = S.toggleMembers([], members, false);
out.structure = {
pieces: split.length,
groundPieces: split.filter((p) => p.line === "ground").length,
members: members.length,
chosen: chosen.length,
again: S.toggleMembers(chosen, members, false).length,
additiveOff: S.toggleMembers([...chosen, 99], members, true),
label: S.selectionLabel(
undefined,
chosen.map((i) => split[i]),
),
faces: G.findFaces(split)
.map((f) => Math.round(f.area * 100) / 100)
.sort(),
// 옛 저장 조각(구조물 선 아래 반쪽) — 그대로 다시 고름
oldPick: split.filter((p) => S.samePiece(p, { line: "structure", start: [0, 0], end: [0, 5] }))
.length,
wholeValue: G.piecesValue(
"length",
chosen.map((i) => G.pieceSource(split[i])),
{},
),
};
// collectPieces — 구조물 선(설계선 표식 없음)에 묶음 · 설계선은 없음
const axes = {
widthPx: 600,
heightPx: 300,
pixelsPerMeter: 10,
maxOffset: 20,
displayMax: 120,
elevationMid: 110,
exaggeration: 1,
};
const px = ([offset, elevation]) =>
[
CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter,
CROSS_PAD.top +
(axes.displayMax -
(axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) *
axes.pixelsPerMeter,
].join(",");
const el = (classes, points, attrs = {}) => ({
tagName: "polyline",
classList: { contains: (name) => classes.includes(name) },
hasAttribute: (name) => name in attrs,
getAttribute: (name) => (name === "points" ? points.map(px).join(" ") : (attrs[name] ?? null)),
});
const collected = S.collectPieces(
[
el(
["b06-chart__design-cross"],
[
[-10, 110],
[10, 110],
],
{ "data-design-line": "" },
),
el(
["b06-chart__design-cross"],
[
[0, 105],
[0, 115],
],
),
],
axes,
);
out.collected = collected.map((p) => ({ line: p.line, group: p.group ?? null }));
process.stdout.write(JSON.stringify(out));
+2 -2
View File
@@ -54,8 +54,8 @@ def test_축_고정_도형_층만_움직임_눈금_다시(out):
assert out["axesTransform"] is None and out["clipTransform"] is None
assert out["ticksChanged"] is True and out["ticksAfter"] > 0
assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로
# 확대 — 십자는 중심(300,150)에서 배율 되돌림
assert out["centerTransform"].startswith("translate(300 150) scale(0.869")
# 확대 — 십자는 중심(319,142 · 칸 가운데)에서 배율 되돌림
assert out["centerTransform"].startswith("translate(319 142) scale(0.869")
assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만
assert out["noLayer"] is None # 도형 층 없음 = 줌 없음
+1 -1
View File
@@ -81,5 +81,5 @@ def test_배선():
in view
)
src = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split())
assert "drawn.homeScale" in src and "homeScale, onChange" in src
assert "drawn.homeScale" in src and "homeScale, home, onChange" in src # 295-2 첫 보기
assert "homeScale: drawn.homeScale" in src
@@ -0,0 +1,118 @@
"""PLAN 295 — 횡단 카드 · [상세] 모달 고르기 차례 · 모달 첫 보기 · 구조물 선 안 나눔(294-5 포함).
- 295-1 카드 = 판정면 넓이 큰 것부터 아래(작은 것 위) · 보라 선(사면 손잡이) 맨 위 · 띠 있는 성토 사면 누름 = 사면 ·
끌기 = 고름 아님 / 모달 `pickAt` = 선 > 작은 면 > 큰 면 · 띠(띠는 영역보다 작을 때만)
- 295-2 모달 첫 보기 = 중심점이 칸 가운데 · 그림 전체가 들어가게 축마다 큰 쪽 반폭
- 295-3 모달 구조물 선 = 다른 선을 나누는 기준 그대로 · 고를 땐 한 선 통째 · 닫힌 영역 그대로 · 옛 조각 다시 고름
실제 TS 를 `helper_295_pick_order_detail.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
B06 = os.path.join(ROOT, "B06_Section")
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
def _read(name: str) -> str:
with open(os.path.join(B06, name), encoding="utf-8") as f:
return f.read().replace(chr(13), "")
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_295_pick_order_detail.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_카드_작은_것_위_보라_선_맨_위(out):
# 띠(14400) > 벽(4000) > 형상(100) > 관(25) · 보라 선 둘 맨 위 · 면적 띠는 맨 아래 그대로
assert out["cardOrder"] == ["band", "chute", "wall", "shape", "pipe", "slope", "top"]
assert out["area"] == 200
def test_카드_띠_있는_성토_사면_누름은_사면_294_5(out):
assert out["slopeClickOld"] == "chute" # 옛 차례 = 띠가 사면을 가림
assert out["slopeClick"] == "slope"
assert out["chuteClick"] == "chute" # 선 밖 띠 자리는 띠
assert out["pipeOverWall"] == "pipe"
def test_카드_끌기는_고름_아님(out):
assert out["dragStopped"] is True
assert out["clickStopped"] is False
def test_모달_선_작은_면_큰_면_띠(out):
m = out["modal"]
assert m["line"] == {"piece": 4}
assert m["small"] == {"face": 1}
assert m["big"] == {"face": 0}
assert m["bandBigger"] == {"face": 1} # 띠보다 좁은 영역이 앞(옛 「띠가 앞」 폐기)
assert m["bandSmaller"] == {"band": True}
assert m["bandBigFace"] == {"band": True}
assert m["bandOnly"] == {"band": True}
assert m["lineOverBand"] == {"piece": 4}
def test_모달_첫_보기_중심점_가운데(out):
h = out["home"]
assert h["center"] == pytest.approx(h["plotCenter"])
assert h["scale"] == pytest.approx(140 / 210) # 아래로 먼 쪽 210 px 이 반높이에
assert h["fits"] is True
assert h["flat"] == pytest.approx(2) # 세로가 낮으면 가로(카드 반폭) 배율
def test_모달_구조물_선_한_조각(out):
s = out["structure"]
assert s["groundPieces"] == 3 # 구조물 선이 원지반선을 나눔(기준 그대로)
assert s["members"] == 2 and s["chosen"] == 2 # 나뉜 조각 둘 = 한 선 통째
assert s["again"] == 0
assert s["additiveOff"] == [99]
assert s["label"].startswith("구조물 선 길이 10.00m")
assert s["wholeValue"] == pytest.approx(10)
assert out["collected"] == [
{"line": "design", "group": None},
{"line": "design", "group": None},
{"line": "structure", "group": 1},
{"line": "structure", "group": 1},
]
def test_모달_닫힌_영역_옛_조각(out):
s = out["structure"]
assert s["faces"] == [50, 50]
assert s["oldPick"] == 1
def test_배선():
view = _read("B06_Section_UI_Cross_View.ts")
assert view.index("setLineActive(pickedLineAt(") < view.index("stackPickTargets(plotLayer)")
select = " ".join(_read("B06_Section_UI_Cross_Detail_Select.ts").split())
assert "groupMembers(pieces, pick.piece)" in select
assert 'pick && "face" in pick' in select
table = _read("B06_Section_UI_Cross_Detail_Table.ts")
assert 'pointsArea(hit.getAttribute("points"))' in table
detail = " ".join(_read("B06_Section_UI_Cross_Detail.ts").split())
assert "detailHomeView(layerPoints(layer), [cx, cy], plot, homeScale, 16)" in detail
for name in ("B06_Section_UI_Cross_View.ts", "B06_Section_UI_Cross_Detail_Select.ts"):
assert len(_read(name).splitlines()) <= 700