feat(b06): 상세 닫힌 영역 고름 · 면적 · 둘레 · 표 칸 → 그래프 · 표 강조 바깥 한 줄 (259-2 · 259-3)

- 나눈 조각으로 면 찾기(열린 영역 제외) · 영역 안 누름 = 고름 · 채움 강조 · 면적 · 둘레
- 선 고름 경사비율 · 닫힌 선 둘레 · 띠 고름 값 글
- 표 칸 누름 = 저장된 조각 그래프 고름 · 띠 강조 + 값 글
- 고른 칸 묶음 바깥 한 줄(겹선 없음) · 줄 물들임 없앰 · 합친 묶음 이름 칸 키 맞춤
- 259-2 · 259-3 이 같은 파일(상세 표 배선)이라 한 커밋

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
This commit is contained in:
2026-10-11 14:10:35 +09:00
co-authored by Claude Opus 5.5
parent eaf9337638
commit 6dd6ced454
11 changed files with 575 additions and 48 deletions
@@ -130,4 +130,93 @@ out.rockSource = S.selectionSource("length", [rockPieces[1]]);
out.rockLocal = T.localCellValue(out.rockSource, design, samples);
out.rockLength = G.pathLength(rockPieces[1].points);
/* ③ 닫힌 영역(259-2) — 면 찾기 · 열린 영역 제외 · 영역 고름 값 · 붙은 두 영역 = 맞닿은 변 뺌 */
const faces = G.findFaces(pieces);
out.faces = faces.map((face) => ({
area: face.area,
lines: face.edges.map((i) => pieces[i].line).sort(),
}));
const one = faces.map((face) =>
S.regionLabel(
[face],
face.edges.map((i) => pieces[i]),
),
);
out.faceLabels = one;
const union = S.regionEdges(faces);
const unionPieces = union.map((i) => pieces[i]);
out.union = {
lines: unionPieces.map((p) => p.line).sort(),
label: S.regionLabel(faces, unionPieces),
selectLabel: S.selectionLabel(design, unionPieces),
source: S.selectionSource("area", unionPieces),
};
out.union.local = T.localCellValue(out.union.source, design, samples);
// 열린 원지반만 = 면 없음
out.openFaces = G.findFaces(pieces.filter((p) => p.line === "ground")).length;
// 정사각형 하나 + 매달린 꼬리 — 꼬리 지우고 면 하나(넓이 1)
out.square = G.findFaces([
{
line: "ground",
points: [
[0, 0],
[1, 0],
],
},
{
line: "design",
points: [
[1, 0],
[1, 1],
[0, 1],
],
},
{
line: "rock",
points: [
[0, 1],
[0, 0],
],
},
{
line: "spoil",
points: [
[1, 1],
[3, 3],
],
},
]).map((face) => ({ area: face.area, edges: face.edges.sort() }));
/* ④ 표 칸 → 그래프(259-2) — 저장된 조각 짝 · 칸 그래프 짝 · 띠 값 글 */
const stored = G.pieceSource(rockPieces[1]);
out.same = {
forward: S.samePiece(rockPieces[1], stored),
reverse: S.samePiece(rockPieces[1], { ...stored, start: stored.end, end: stored.start }),
otherLine: S.samePiece(rockPieces[1], { ...stored, line: "ground" }),
otherPiece: S.samePiece(rockPieces[0], stored),
};
const table = {
cells: {
fence: out.rockSource,
cut_soil: { src: "area", key: "cut_rock" },
"r:r1:0": { src: "text", text: "글" },
},
rows: [],
columns: [],
};
out.cellGraph = ["fence", "cut_soil", "r:r1:0", "embankment"].map((key) => T.cellGraph(table, key));
out.bandLabel = [
T.bandLabel("cut_soil", { cut_soil_area_m2: 1.234 }),
T.bandLabel("cut_soil", {}),
T.bandLabel("ground", { cut_soil_area_m2: 1 }),
];
/* ⑤ 표 강조 바깥 한 줄(259-3) — 합친 묶음 이름 칸(두 줄) + 둘째 줄 이름 · 값 = ㄴ 모양 */
const P = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Pick.ts"));
out.outline = P.unionOutline([
[0, 0, 10, 20],
[10, 10, 20, 20],
[20, 10, 30, 20],
]);
process.stdout.write(JSON.stringify(out));
+6 -3
View File
@@ -80,8 +80,9 @@ def test_상세_표_칸_키_누름_배선():
) as f:
src = f.read()
assert "element.dataset.key = key" in src
assert "highlightDetail(svg, state.section.design, picked)" in src
assert 'closest<HTMLElement>("[data-key]")' in src
# 259-2 — 저장 띠 출처면 그 띠 · 저장 조각이면 강조 없음(그래프 고름) · 값 칸(data-cell)도 누름
assert "highlightDetail(svg, state.section.design, band)" in src
assert 'closest<HTMLElement>("[data-key], [data-cell]")' in src
def test_상세_큰_창_공용_막_그림_글_표_순서():
@@ -93,6 +94,8 @@ def test_상세_큰_창_공용_막_그림_글_표_순서():
css = f.read().replace(chr(13), "")
assert "wide: true" in src and "backdropClass" not in src
assert "body.append(cad, note, tableBox)" in src
assert "pointer-events: none" not in css and "b06-detail__backdrop" not in css
# 막 없음 — 259-3 표 강조 바깥 줄(누름 통과)만 pointer-events: none
backdrop = css.split(".b06-detail__pick-outline {")[0]
assert "pointer-events: none" not in backdrop and "b06-detail__backdrop" not in css
assert ".ui-modal.b06-detail > .ui-modal__body {\n overflow: hidden;" in css
assert "flex: 1;\n min-height: 0;" in css and "height: 100%;" in css # 173-4 칸 가득
+10 -6
View File
@@ -105,10 +105,12 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸():
".b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);",
".b06-detail__cad .b06-chart__spoil-fill.is-active {\n stroke: var(--color-danger);",
".b06-detail__qtable .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%",
".b06-detail__qtable tr:has(.is-picked) {",
".b06-detail__qtable [data-key][data-empty] {\n cursor: default;",
):
assert selector in css, selector
# 259-3 — 줄 물들임 · 칸마다 테두리 없앰(묶음 바깥 한 줄)
assert "tr:has(.is-picked)" not in css
assert "inset 0 0 0 1px var(--color-danger)" not in css
assert "royal-amethyst" not in css and "--color-warning);" not in css.split("/* 그림 강조")[1]
assert (
".b06-detail__qtable [data-key]:hover {\n background: color-mix(in srgb, var(--color-danger) 8%"
@@ -118,11 +120,13 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸():
def test_빈칸_누름_없음_이름_칸_첫_키():
src = _read("B06_Section_UI_Cross_Detail.ts")
assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src
assert 'if (!key || el.hasAttribute("data-empty")) return;' in src
# 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키
assert "nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0)" in src
assert "nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1)" in src
assert "!present.has(el.dataset.key) && !tablePick.graph(el.dataset.key)" in src
assert '(!el.dataset.key || el.hasAttribute("data-empty"))) return;' in src
# 179-4 — 묶음 이름 칸 = 하위 줄만큼(양식 2줄 · 더한 줄만큼 늘어남) · 그 줄 첫 값 칸 키 ·
# 259-3 덮는 줄 키 모두(data-keys)
flat = " ".join(src.split())
assert "nameCell( groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0," in flat
assert "nameCell( groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1," in flat
def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만():
+2 -2
View File
@@ -117,11 +117,11 @@ def test_원지반_포함_닫힌_영역_넓이(out):
def test_정보창_값(out):
label = out["label"]
length = 2 * hypot(4 / 3, 2) + 4 + 2 * CROSS_X
assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡"
assert label["ring"] == f"4개 · 길이 합 {length:.2f}m\n면적 {AREA:.2f}㎡ · 둘레 {length:.2f}m"
assert label["open"].endswith("면적 닫히지 않음")
assert label["single"] == "길이 4.00m · 경사비율 0.0%\n수평 4.00m · 높이 0.00m" # 카드 꼴
assert label["ground"] == (
f"원지반선 길이 {2 * CROSS_X:.2f}m\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m"
f"원지반선 길이 {2 * CROSS_X:.2f}m · 경사비율 0.0%\n수평 {2 * CROSS_X:.2f}m · 높이 0.00m"
)
+89 -1
View File
@@ -6,6 +6,9 @@
거리이정표 꼴(빈 곳 왼쪽 끌기 팬)은 끌기 끝 click 만 막음.
- 259-1 암 경계선 — 설계선과 만나는 점에서 나뉨 · 고름 값 · 저장 꼴 · 서버 `pick_lines` 거울 ·
암 프리셋 아니면 다시 고름.
- 259-2 닫힌 영역 — 면 찾기(열린 영역 · 매달린 꼬리 제외) · 영역 고름 값(면적 · 둘레) · 붙은 영역 =
맞닿은 변 뺌 · 서버 거울 · 표 칸 → 저장된 조각 · 띠 짝 · 띠 값 글.
- 259-3 표 강조 — 고른 칸 묶음 바깥 한 줄(칸 사이 겹선 없음).
./venv/Scripts/python.exe -m pytest resources/tester/test_259_detail_region.py -q
"""
@@ -13,6 +16,7 @@
import json
import os
import subprocess
from math import hypot
from pathlib import Path
import pytest
@@ -45,6 +49,7 @@ AXES = {
"exaggeration": 2,
}
ROCK_X = 2 + 4 / 6 # 경사(4 : 6)가 암 경계선 99 m 와 만나는 offset
GROUND_X = 2 + 4 / 3 # 경사가 원지반 100 m 와 만나는 offset
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
@@ -98,7 +103,9 @@ def test_암_경계선이_설계선과_만나는_점에서_나뉨(out):
def test_암_경계선_고름_값_저장_꼴(out):
length = 2 * ROCK_X
assert out["rockLength"] == pytest.approx(length, abs=1e-3)
assert out["rockLabel"] == f"암 경계선 길이 {length:.2f}m\n수평 {length:.2f}m · 높이 0.00m"
assert out["rockLabel"] == (
f"암 경계선 길이 {length:.2f}m · 경사비율 0.0%\n수평 {length:.2f}m · 높이 0.00m"
)
source = out["rockSource"]
assert source["src"] == "length" and source["pieces"][0]["line"] == "rock"
patch = CrossSectionPatch(chainage_m=40.0, detail_table={"cells": {"fence": source}})
@@ -113,3 +120,84 @@ def test_암_경계선_서버_거울(out):
soil = {**DESIGN, "geometry_preset": "soil"}
assert pick_lines(soil, SAMPLES)["rock"] == []
assert cell_value(out["rockSource"], soil, SAMPLES) == (None, None, True)
# ── 259-2 닫힌 영역 · 영역 고름 · 면적 ────────────────────────────────────
SLOPE_1M = hypot(4 / 6, 1.0) # 경사 조각 높이 1 m 길이
FACE_TOP = (2 * GROUND_X + 2 * ROCK_X) / 2 # 원지반 100 ~ 암 99 사이 사다리꼴
FACE_LOW = (2 * ROCK_X + 4) / 2 # 암 99 ~ 바닥 98 사이 사다리꼴
def test_면_찾기_열린_영역_제외(out):
faces = sorted(out["faces"], key=lambda f: -f["area"])
assert [f["area"] for f in faces] == pytest.approx([FACE_TOP, FACE_LOW], abs=1e-6)
assert faces[0]["lines"] == ["design", "design", "ground", "rock"]
assert faces[1]["lines"] == ["design", "design", "design", "rock"]
assert out["openFaces"] == 0 # 원지반만 = 열림
assert out["square"] == [{"area": pytest.approx(1.0), "edges": [0, 1, 2]}] # 꼬리 지움
def test_영역_고름_면적_둘레(out):
top = 2 * GROUND_X + 2 * ROCK_X + 2 * SLOPE_1M
low = 2 * ROCK_X + 4 + 2 * SLOPE_1M
assert sorted(out["faceLabels"]) == sorted(
[
f"영역 면적 {FACE_TOP:.2f}㎡\n둘레 {top:.2f}m",
f"영역 면적 {FACE_LOW:.2f}㎡\n둘레 {low:.2f}m",
]
)
def test_붙은_두_영역_맞닿은_변_뺌(out):
union = out["union"]
assert union["lines"] == ["design"] * 5 + ["ground"] # 암 경계선 빠짐
area = FACE_TOP + FACE_LOW
around = 2 * GROUND_X + 4 + 4 * SLOPE_1M
assert union["label"] == f"영역 2개 · 면적 {area:.2f}㎡\n둘레 {around:.2f}m"
assert (
union["selectLabel"]
== f"6개 · 길이 합 {around:.2f}m\n면적 {area:.2f}㎡ · 둘레 {around:.2f}m"
)
assert union["local"] == pytest.approx(area, abs=1e-3)
number, _text, again = cell_value(union["source"], DESIGN, SAMPLES)
assert not again and number == pytest.approx(union["local"], abs=1e-6)
# ── 259-2 표 칸 → 그래프 ─────────────────────────────────────────────────
def test_표_칸_저장된_조각을_그래프에서_고름(out):
assert out["same"] == {
"forward": True,
"reverse": True,
"otherLine": False,
"otherPiece": False,
}
pieces, band, text, none = out["cellGraph"]
assert pieces == {"pieces": out["rockSource"]["pieces"]}
assert band == {"band": "cut_rock"}
assert text is None and none is None
def test_띠_값_글(out):
assert out["bandLabel"] == ["깍기(토사) 면적 1.23㎡", None, None]
# ── 259-3 표 강조 바깥 한 줄 ──────────────────────────────────────────────
def test_고른_칸_묶음_바깥_한_줄(out):
parts = {"M" + p for p in out["outline"].split("M") if p}
assert parts == {
"M0 0H10",
"M0 0V10",
"M10 0V10",
"M0 20H10",
"M0 10V20",
"M10 10H20",
"M10 20H20",
"M20 10H30",
"M20 20H30",
"M30 10V20",
} # 칸 사이(10 · 20 세로) 겹선 없음