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
+55 -14
View File
@@ -24,7 +24,12 @@ import type {
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { CROSS_PAD, inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common";
import { DETAIL_TARGETS, highlightDetail, presentKeys } from "./B06_Section_UI_Cross_Detail_Pick";
import {
DETAIL_TARGETS,
highlightDetail,
outlinePicked,
presentKeys,
} from "./B06_Section_UI_Cross_Detail_Pick";
import {
attachSvgZoomPan,
type ZoomPanHandle,
@@ -132,18 +137,25 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
if (repick.has(key)) td.dataset.repick = "";
return mark(td, { cell: key, row, col }, isAdded(key));
};
// 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2)
// 이름 칸 — 더한 행이면 글 손질(자리 = 좌 0 · 중 1 · 우 2) · 합친 묶음 = 덮는 줄 키 모두(259-3)
const nameCell = (
label: string | null,
span: { col?: number; row?: number } | undefined,
key: string | null,
row: string,
slot: number,
keys?: Array<string | null>,
): HTMLElement => {
const added = row.startsWith("r");
const data = { row, label: added ? `${row}:${slot}` : undefined };
const data = {
row,
label: added ? `${row}:${slot}` : undefined,
keys: keys && keys.length > 1 ? keys.filter(Boolean).join(" ") : undefined,
};
return mark(cell("th", label ?? "", span, key), data, added);
};
const groupKeys = (slots: typeof layout.left, index: number) =>
slots.slice(index, index + groupSpan(slots, index)).map((slot) => slot[2]);
const table = document.createElement("table");
table.className = "b06-detail__qtable";
const titleRow = table.insertRow();
@@ -158,13 +170,31 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
const row = table.insertRow();
// 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2) · 묶음 = 하위 줄만큼(179-4)
if (groupL !== null && subL !== null)
row.append(nameCell(groupL, { row: groupSpan(layout.left, index) }, keyL, id, 0));
row.append(
nameCell(
groupL,
{ row: groupSpan(layout.left, index) },
keyL,
id,
0,
groupKeys(layout.left, index),
),
);
else if (groupL !== null) row.append(nameCell(groupL, { col: 2 }, keyL, id, 0));
if (subL !== null) row.append(nameCell(subL, undefined, keyL, id, 0));
row.append(valueCell(keyL, id));
const [groupM, subM, keyM] = layout.middle[index];
if (groupM !== null && subM !== null)
row.append(nameCell(groupM, { row: groupSpan(layout.middle, index) }, keyM, id, 1));
row.append(
nameCell(
groupM,
{ row: groupSpan(layout.middle, index) },
keyM,
id,
1,
groupKeys(layout.middle, index),
),
);
else if (groupM !== null) row.append(nameCell(groupM, { col: 2 }, keyM, id, 1));
if (subM !== null) row.append(nameCell(subM, undefined, keyM, id, 1));
row.append(valueCell(keyM, id));
@@ -289,24 +319,35 @@ export function openCrossDetail(section: CrossSection): void {
tableBox.className = "b06-detail__table";
body.append(cad, note, tableBox);
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2)
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2) ·
// 저장된 조각 · 띠 칸 = 그래프에서 고름 + 값 글(259-2) · 고른 칸 묶음 = 바깥 한 줄(259-3)
let picked: string | null = null;
const showPicked = (): void => {
const svg = cad.querySelector("svg");
const present = svg ? presentKeys(svg, state.section.design) : new Set<string>();
if (picked && !present.has(picked)) picked = null;
tableBox.querySelectorAll<HTMLElement>("[data-key]").forEach((el) => {
el.classList.toggle("is-picked", el.dataset.key === picked);
el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""));
const graph = picked ? tablePick.graph(picked) : null;
if (picked && !graph && !present.has(picked)) picked = null;
tableBox.querySelectorAll<HTMLElement>("[data-key], [data-cell]").forEach((el) => {
const keys = (el.dataset.keys ?? el.dataset.key ?? el.dataset.cell ?? "").split(" ");
el.classList.toggle("is-picked", picked !== null && keys.includes(picked));
if (el.dataset.key)
el.toggleAttribute(
"data-empty",
!present.has(el.dataset.key) && !tablePick.graph(el.dataset.key),
);
});
if (svg) highlightDetail(svg, state.section.design, picked);
outlinePicked(tableBox);
const band = graph && "band" in graph ? graph.band : graph ? null : picked;
if (svg) highlightDetail(svg, state.section.design, band);
};
tableBox.addEventListener("click", (event) => {
const el = (event.target as HTMLElement).closest<HTMLElement>("[data-key]");
const key = el?.dataset.key;
if (!key || el.hasAttribute("data-empty")) return;
const el = (event.target as HTMLElement).closest<HTMLElement>("[data-key], [data-cell]");
const key = el?.dataset.key ?? el?.dataset.cell;
if (!el || !key) return;
if (!tablePick.graph(key) && (!el.dataset.key || el.hasAttribute("data-empty"))) return;
picked = picked === key ? null : key;
showPicked();
tablePick.showCell(picked);
});
// 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4)
@@ -5,7 +5,7 @@
* 짝(① 파이썬 · TS) = `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Table_Edit.py`
* (`piece_path` · `sub_path` · `path_length` · `ring_area` · `ground_runs` — [저장] 뒤 정본 셈) ·
* 거울 시험 `resources/tester/test_209_1_detail_select.py`.
* 나눔(`splitAtCrossings`)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
* 나눔(`splitAtCrossings`) · 닫힌 영역(`findFaces` · 259-2)은 화면 몫 — 서버는 저장된 조각 두 끝만 검산.
* ========================================================================== */
import type { CrossDesign, CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
@@ -227,6 +227,89 @@ export function splitAtCrossings(lines: SplitPiece[]): SplitPiece[] {
return out;
}
/* ── 닫힌 영역(259-2 · 화면 몫) ─────────────────────────────────────────── */
/** 나눈 조각으로 둘러싸인 면 하나 — 둘레 조각 번호 · 고리(m) · 넓이(㎡). */
export interface Face {
edges: number[];
ring: Pt[];
area: number;
}
/** 꺾은선이 그 끝에서 떠나는 방향(라디안) — 아주 짧은 첫 토막은 건너뜀. */
function leaving(points: Pt[]): number {
const [x0, y0] = points[0];
const next = points.find((p) => dist(p, points[0]) > 1e-6) ?? points[points.length - 1];
return Math.atan2(next[1] - y0, next[0] - x0);
}
/**
* 조각(변)으로 된 평면 그래프의 면 — 끝이 `TOUCH_M` 안이면 같은 마디 · 열린 끝 조각(마디 차수 1)은
* 거듭 지움(열린 영역 제외) · 각 반변에서 도착 마디의 시계 방향 다음 변으로 돌아 면을 셈 ·
* 넓이가 양(반시계)인 것만 = 바깥 면 뺌.
* ponytail: 안에 떠 있는 섬(다리 없음)은 바깥 면의 구멍으로 안 뺌 — 필요하면 포함 판정 더함.
*/
export function findFaces(pieces: SplitPiece[]): Face[] {
const nodes: Pt[] = [];
const nodeOf = (p: Pt): number => {
const found = nodes.findIndex((q) => dist(p, q) <= TOUCH_M);
return found >= 0 ? found : nodes.push(p) - 1;
};
const ends = pieces.map((piece) => [
nodeOf(piece.points[0]),
nodeOf(piece.points[piece.points.length - 1]),
]);
const alive = ends.map(([a, b]) => a !== b);
for (let changed = true; changed;) {
const degree = new Array<number>(nodes.length).fill(0);
ends.forEach(([a, b], i) => alive[i] && (degree[a]++, degree[b]++));
changed = false;
ends.forEach(([a, b], i) => {
if (alive[i] && (degree[a] < 2 || degree[b] < 2)) [alive[i], changed] = [false, true];
});
}
// 반변 h = 2i(앞 → 뒤) · 2i+1(뒤 → 앞)
const path = (h: number): Pt[] => {
const points = pieces[h >> 1].points;
return h & 1 ? [...points].reverse() : points;
};
const head = (h: number): number => ends[h >> 1][h & 1 ? 0 : 1];
const out = nodes.map(() => [] as Array<[number, number]>);
ends.forEach(([a, b], i) => {
if (!alive[i]) return;
out[a].push([leaving(path(2 * i)), 2 * i]);
out[b].push([leaving(path(2 * i + 1)), 2 * i + 1]);
});
out.forEach((list) => list.sort((p, q) => p[0] - q[0]));
const next = (h: number): number => {
const list = out[head(h)];
const at = list.findIndex(([, e]) => e === (h ^ 1));
return list[(at - 1 + list.length) % list.length][1];
};
const faces: Face[] = [];
const seen = new Set<number>();
ends.forEach((_, i) => {
if (!alive[i]) return;
for (const start of [2 * i, 2 * i + 1]) {
if (seen.has(start)) continue;
const cycle: number[] = [];
for (let h = start; !seen.has(h) && cycle.length <= 2 * pieces.length; h = next(h)) {
seen.add(h);
cycle.push(h);
}
const ring = cycle.flatMap((h) => path(h).slice(1));
let twice = 0;
ring.forEach((p, k) => {
const q = ring[(k + 1) % ring.length];
twice += p[0] * q[1] - q[0] * p[1];
});
if (twice / 2 > 1e-9)
faces.push({ edges: [...new Set(cycle.map((h) => h >> 1))], ring, area: twice / 2 });
}
});
return faces;
}
/** 조각 → 저장 꼴(두 끝 · 0.1 mm 반올림). */
export function pieceSource(piece: SplitPiece): CrossPickPiece {
const round = (p: Pt): Pt => [Math.round(p[0] * 1e4) / 1e4, Math.round(p[1] * 1e4) / 1e4];
@@ -6,6 +6,7 @@
* (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려
* `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리.
* 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖.
* 고른 칸 묶음 = 바깥 한 줄(`outlinePicked` · 259-3).
* ponytail: 판정 선의 구조물 트림 여부는 복제에서 모름 → 자리로만 가름(트림 선은 놓인 구역을 따름).
* ========================================================================== */
@@ -75,3 +76,48 @@ export function presentKeys(svg: Element, design: CrossDesign | undefined): Set<
Object.keys(DETAIL_TARGETS).filter((key) => targetsOf(svg, design, key).length > 0),
);
}
/* ── 표 강조 바깥 한 줄(259-3) ──────────────────────────────────────────── */
/** 직사각형들(좌 · 위 · 우 · 아래)의 합친 모양 바깥 줄 — 격자로 나눠 덮인 칸과 안 덮인 칸 사이 변만(SVG path). */
export function unionOutline(rects: number[][]): string {
const xs = [...new Set(rects.flatMap((r) => [r[0], r[2]]))].sort((a, b) => a - b);
const ys = [...new Set(rects.flatMap((r) => [r[1], r[3]]))].sort((a, b) => a - b);
const covered = (i: number, j: number): boolean =>
i >= 0 &&
j >= 0 &&
i + 1 < xs.length &&
j + 1 < ys.length &&
rects.some((r) => r[0] <= xs[i] && xs[i + 1] <= r[2] && r[1] <= ys[j] && ys[j + 1] <= r[3]);
const parts: string[] = [];
for (let i = 0; i + 1 < xs.length; i++)
for (let j = 0; j + 1 < ys.length; j++) {
if (!covered(i, j)) continue;
const [x0, x1, y0, y1] = [xs[i], xs[i + 1], ys[j], ys[j + 1]];
if (!covered(i, j - 1)) parts.push(`M${x0} ${y0}H${x1}`);
if (!covered(i, j + 1)) parts.push(`M${x0} ${y1}H${x1}`);
if (!covered(i - 1, j)) parts.push(`M${x0} ${y0}V${y1}`);
if (!covered(i + 1, j)) parts.push(`M${x1} ${y0}V${y1}`);
}
return parts.join("");
}
/** 고른 칸(`.is-picked`) 묶음에 바깥 한 줄 — 칸마다 테두리를 그리면 붙은 칸에 겹선이 생김. */
export function outlinePicked(box: HTMLElement): void {
box.querySelector(".b06-detail__pick-outline")?.remove();
const cells = [...box.querySelectorAll(".is-picked")];
if (!cells.length) return;
const base = box.getBoundingClientRect();
const rects = cells.map((cell) => {
const r = cell.getBoundingClientRect();
return [r.left - base.left, r.top - base.top, r.right - base.left, r.bottom - base.top].map(
(v) => Math.round(v),
);
});
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "b06-detail__pick-outline");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", unionOutline(rects));
svg.append(path);
box.append(svg);
}
+109 -11
View File
@@ -3,7 +3,10 @@
* [상세] 그림 선 고르기(209-1) — 설계선 · 구조물 선 · 원지반선 · 사토장선 · 암 경계선(259-1)을 만나는 점에서 조각으로
* 나눠(`_Cross_Detail_Geom.splitAtCrossings`) 조각마다 투명 판정 선 · 누름 = 하나 고름 ·
* Shift/Ctrl+누름 = 더하기/빼기 · 고른 것 값 = [+ − ⤢] 왼쪽 두 줄(카드 179-1 · 195-1 꼴):
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이(안 닫히면 「닫히지 않음」).
* 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」).
* 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각)
* 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움.
* 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`).
* 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀).
* ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때.
* ========================================================================== */
@@ -17,16 +20,21 @@ import {
pieceLabel,
} from "./B06_Section_UI_Cross_Design_Pick";
import {
findFaces,
LINE_TOLERANCE_M,
pathLength,
pieceSource,
ringArea,
splitAtCrossings,
type Face,
type Pt,
type SplitPiece,
} from "./B06_Section_UI_Cross_Detail_Geom";
const SVG_NS = "http://www.w3.org/2000/svg";
export const PIECE_HIT_CLASS = "b06-detail__piece";
/** 닫힌 영역 판정 면(259-2) — 조각 판정 선 밑 · 투명 채움. */
export const FACE_HIT_CLASS = "b06-detail__face";
/** 화면 px → m(카드 `x` · `toDisplayY` 의 역 · 축 옵션). */
export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt {
@@ -124,16 +132,45 @@ export function selectionLabel(design: CrossDesign | undefined, pieces: SplitPie
to: { offset: to[0], elevation: to[1] },
structure: piece.line === "structure",
});
const horizontal = Math.abs(to[0] - from[0]);
const height = Math.abs(to[1] - from[1]);
const grade = horizontal > 0 ? `${((height / horizontal) * 100).toFixed(1)}%` : "수직";
return (
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m\n` +
`수평 ${Math.abs(to[0] - from[0]).toFixed(2)}m · 높이 ${Math.abs(to[1] - from[1]).toFixed(2)}m`
`${LINE_NAME[piece.line]} 길이 ${pathLength(piece.points).toFixed(2)}m · 경사비율 ${grade}\n` +
`수평 ${horizontal.toFixed(2)}m · 높이 ${height.toFixed(2)}m`
);
}
const total = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
const area = ringArea(pieces.map((piece) => piece.points));
return (
`${pieces.length}개 · 길이 합 ${total.toFixed(2)}m\n` +
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡`)
(area === null ? "면적 닫히지 않음" : `면적 ${area.toFixed(2)}㎡ · 둘레 ${total.toFixed(2)}m`)
);
}
/** 고른 영역 값 두 줄(259-2) — 넓이 합 / 둘레(고른 둘레 조각 길이 합 · 붙은 영역은 맞닿은 변 뺌). */
export function regionLabel(faces: Face[], pieces: SplitPiece[]): string {
const area = faces.reduce((sum, face) => sum + face.area, 0);
const around = pieces.reduce((sum, piece) => sum + pathLength(piece.points), 0);
const count = faces.length > 1 ? `${faces.length}개 · ` : "";
return `영역 ${count}면적 ${area.toFixed(2)}㎡\n둘레 ${around.toFixed(2)}m`;
}
/** 영역 여럿 → 둘레 조각 — 둘 이상이 맞닿은 변은 짝수 번 나와 빠짐. */
export function regionEdges(faces: Face[]): number[] {
const count = new Map<number, number>();
for (const face of faces)
for (const edge of face.edges) count.set(edge, (count.get(edge) ?? 0) + 1);
return [...count].filter(([, n]) => n % 2).map(([edge]) => edge);
}
/** 그림 조각이 저장된 조각(두 끝 · 어느 방향이든)과 같은 자리인가(259-2 표 칸 → 그래프). */
export function samePiece(piece: SplitPiece, stored: CrossPickPiece): boolean {
if (piece.line !== stored.line) return false;
const near = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= LINE_TOLERANCE_M;
const [a, b] = [piece.points[0], piece.points[piece.points.length - 1]];
return (
(near(a, stored.start) && near(b, stored.end)) || (near(a, stored.end) && near(b, stored.start))
);
}
@@ -156,6 +193,10 @@ export interface DetailSelect {
setMode: (kind: "area" | "length" | null) => void;
source: (kind: "area" | "length") => ReturnType<typeof selectionSource>;
clear: () => void;
/** 저장된 조각을 그림에서 고름(259-2 표 칸 누름) · null = 비움. */
choose: (stored: CrossPickPiece[] | null) => void;
/** 고름 없이 값 글만(띠 · 259-2) — 다음 누름에 지워짐. */
note: (text: string | null) => void;
detach: () => void;
}
@@ -171,11 +212,18 @@ export function attachDetailSelect(input: {
picked: (additive: boolean) => void;
/** [칸에 넣기] 단추. */
commit: () => void;
/** 면적 고르기 중 그 자리에 면적 띠가 있나 — 있으면 영역 대신 띠(표가 맡음). */
band?: (event: MouseEvent) => boolean;
}): DetailSelect {
const { cad } = input;
let pieces: SplitPiece[] = [];
let hits: SVGPolylineElement[] = [];
let faces: Face[] = [];
let faceEls: SVGPolygonElement[] = [];
let selected: number[] = [];
/** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */
let chosenFaces: number[] = [];
let noteText: string | null = null;
let mode: "area" | "length" | null = null;
const label = document.createElement("span");
@@ -193,8 +241,21 @@ export function attachDetailSelect(input: {
const show = (): void => {
const chosen = selected.map((i) => pieces[i]);
hits.forEach((hit, i) => hit.classList.toggle("is-selected", selected.includes(i)));
label.textContent = selectionLabel(input.design(), chosen);
label.hidden = !chosen.length;
const same = (edges: number[]) =>
edges.length === selected.length && edges.every((edge) => selected.includes(edge));
const filled = chosenFaces.length
? chosenFaces
: faces.flatMap((face, i) => (same(face.edges) ? [i] : []));
faceEls.forEach((el, i) => el.classList.toggle("is-selected", filled.includes(i)));
label.textContent =
noteText ??
(filled.length
? regionLabel(
filled.map((i) => faces[i]),
chosen,
)
: selectionLabel(input.design(), chosen));
label.hidden = !label.textContent;
put.hidden = !mode || !selectionSource(mode, chosen);
// [+ − ⤢] 왼쪽(카드 꼴) — 그림을 다시 그리면 막대가 새로 생김
const bar = cad.querySelector(".b06-cross-card__zoom");
@@ -202,16 +263,29 @@ export function attachDetailSelect(input: {
};
const onClick = (event: MouseEvent): void => {
const hit = (event.target as Element).closest?.(`.${PIECE_HIT_CLASS}`);
const additive = event.shiftKey || event.ctrlKey || event.metaKey || mode === "area";
const target = event.target as Element;
const hit = target.closest?.(`.${PIECE_HIT_CLASS}`);
const shift = event.shiftKey || event.ctrlKey || event.metaKey;
const additive = shift || mode === "area";
noteText = null;
if (!hit) {
if (!additive && !mode && selected.length) {
selected = [];
const face = target.closest?.(`.${FACE_HIT_CLASS}`);
if (face && !(mode === "area" && input.band?.(event))) {
// 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter)
event.stopPropagation();
const index = faceEls.indexOf(face as SVGPolygonElement);
chosenFaces = toggleSelection(chosenFaces, index, shift);
selected = regionEdges(chosenFaces.map((i) => faces[i]));
show();
input.picked(true);
return;
}
if (!additive && !mode) [selected, chosenFaces] = [[], []];
show();
return;
}
event.stopPropagation();
chosenFaces = [];
selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive);
show();
input.picked(additive);
@@ -226,7 +300,19 @@ export function attachDetailSelect(input: {
const layer = cad.querySelector<SVGGElement>("g.b06-chart__plot-layer");
pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : [];
selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : []));
chosenFaces = [];
faces = findFaces(pieces);
const toPx = axes ? mToPx(axes) : null;
// 영역 판정 면 먼저(밑) → 조각 판정 선(위)
faceEls = toPx
? faces.map((face) => {
const el = document.createElementNS(SVG_NS, "polygon");
el.setAttribute("points", face.ring.map(toPx).join(" "));
el.setAttribute("class", FACE_HIT_CLASS);
layer?.append(el);
return el;
})
: [];
hits = toPx
? pieces.map((piece) => {
const hit = document.createElementNS(SVG_NS, "polyline");
@@ -248,7 +334,19 @@ export function attachDetailSelect(input: {
selected.map((i) => pieces[i]),
),
clear() {
selected = [];
[selected, chosenFaces] = [[], []];
show();
},
choose(stored) {
chosenFaces = [];
noteText = null;
selected = stored
? pieces.flatMap((piece, i) => (stored.some((s) => samePiece(piece, s)) ? [i] : []))
: [];
show();
},
note(text) {
noteText = text;
show();
},
detach() {
@@ -9,6 +9,8 @@
* 고르기: 면적 = 면적 띠 · 사토장(띠 → 표 키) 또는 닫힌 선 조각들 · 길이 = 선 조각(들) —
* 조각 = 선이 만나는 점에서 나눈 것(209-1 · `_Cross_Detail_Select`) · 저장 = 조각 두 끝 목록.
* 길이는 한 번 누름 = 바로 칸에 · Shift/Ctrl+누름 = 여럿 → Enter · [칸에 넣기].
* 닫힌 영역 안 누름 = 그 영역 둘레 조각(259-2) · 면적 고르기 중 띠 자리는 띠가 앞.
* 표 칸 누름(259-2) = 저장된 조각을 그림에서 다시 고름 · 띠 칸 = 값 글(`cellGraph` · `bandLabel`).
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
@@ -19,7 +21,7 @@ import type {
CrossDetailTable,
CrossSection,
} from "./B06_Section_Api_Fetch";
import type { SectionSample } from "./B06_Section_Api_Types";
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, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select";
@@ -173,6 +175,32 @@ export function areaSourceOf(target: Element): CrossCellSource | null {
return band ? { src: "area", key: band[1] } : null;
}
/** 그 칸이 그래프에서 무엇을 가리키나(259-2) — 저장된 조각 · 띠 키(저장 띠 출처 → 칸 키) · 없음. */
export function cellGraph(
table: CrossDetailTable,
key: string,
): { pieces: CrossPickPiece[] } | { band: string } | null {
const source = table.cells[key];
if (source && "pieces" in source) return { pieces: source.pieces };
if (source && source.src === "area" && "key" in source) return { band: source.key };
return null;
}
const BAND_NAME: Readonly<Record<string, string>> = {
cut_soil: "깍기(토사)",
cut_rock: "깍기(암석)",
ditch_soil: "측구(토사)",
ditch_rock: "측구(암석)",
embankment: "쌓기",
spoil_fill: "사토장",
};
/** 띠 값 글(259-2) — 「깍기(토사) 면적 X㎡」 · 띠 아님 · 값 없음 = null. */
export function bandLabel(key: string, design: CrossDesign | undefined): string | null {
const value = BAND_NAME[key] ? localCellValue({ src: "area", key }, design) : null;
return value === null ? null : `${BAND_NAME[key]} 면적 ${value.toFixed(2)}㎡`;
}
/* ── 칸 값(짝 ① · 서버 `cell_value` 거울) ─────────────────────────────────── */
const AREA_DESIGN: Readonly<Record<string, string>> = {
@@ -280,7 +308,7 @@ export interface DetailTableHost {
let menu: ReturnType<typeof createMapContextMenu> | null = null;
const PICK_HINT = {
area: "면적 띠를 누름 · 또는 닫힌 영역의 선들을 차례로 누름 → Enter · Esc 취소",
area: "면적 띠를 누름 · 또는 닫힌 영역 안 · 선들을 누름 → Enter · Esc 취소",
length: "선을 누름 · Shift/Ctrl+누름 = 여럿 → Enter · Esc 취소",
} as const;
@@ -289,6 +317,10 @@ const PICK_HINT = {
export function attachDetailTable(host: DetailTableHost): {
detach: () => void;
redraw: () => void;
/** 칸 그래프 짝(259-2) — 저장된 조각 · 띠 키 · 없음. */
graph: (key: string) => ReturnType<typeof cellGraph>;
/** 표 칸 누름(259-2) — 저장된 조각을 그림에서 고름 · 띠면 값 글 · null = 비움. */
showCell: (key: string | null) => void;
} {
menu ??= createMapContextMenu("b06-detail");
const ctxMenu = menu;
@@ -323,6 +355,7 @@ export function attachDetailTable(host: DetailTableHost): {
const shown = localCellValue(source, section.design, section.samples);
const td = tableBox.querySelector<HTMLElement>(`td[data-cell="${CSS.escape(key)}"]`);
if (td) td.textContent = shown === null ? "다시 고름" : shown.toFixed(2);
if ("key" in source) select.note(bandLabel(source.key, section.design)); // 띠도 값 글(259-2)
edit((table) => setCell(table, key, source));
};
const commitSelection = (): void => {
@@ -354,14 +387,17 @@ export function attachDetailTable(host: DetailTableHost): {
if (picking?.kind === "length" && !additive) commitSelection();
},
commit: commitSelection,
band: (event) => !!bandAt(event),
});
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로) — 선 조각 누름은 위가 맡음
const bandAt = (event: MouseEvent): Element | undefined =>
document
.elementsFromPoint(event.clientX, event.clientY)
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 조각 누름은 위가 맡음
const onPick = (event: MouseEvent): void => {
if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`))
return;
const hit = document
.elementsFromPoint(event.clientX, event.clientY)
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
const hit = bandAt(event);
const source = hit ? areaSourceOf(hit) : null;
if (!source) return;
event.stopPropagation();
@@ -426,5 +462,15 @@ export function attachDetailTable(host: DetailTableHost): {
tableBox.removeEventListener("keydown", onEnter);
},
redraw: () => select.redraw(),
graph: (key) => cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key),
showCell: (key) => {
const target = key
? cellGraph(detailTableOf(host.section(), host.projectId, host.routeId()), key)
: null;
select.choose(target && "pieces" in target ? target.pieces : null);
// 저장 조각 = 조각 값 글 · 저장 띠 = 그 띠 · 저장 없음 = 칸 키 띠
const band = !target ? key : "band" in target ? target.band : null;
select.note(band ? bandLabel(band, host.section().design) : null);
},
};
}
@@ -28,6 +28,8 @@
.b06-detail__table {
flex: none;
/* 고른 칸 바깥 줄(259-3) 자리 기준 */
position: relative;
}
/* 그림 칸 = 테마 색(173-4 · 카드와 같은 바탕) · 그림은 칸 크기로 새로 그려 가득 */
@@ -81,7 +83,7 @@
color: var(--color-text);
}
/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 · 줄 붉게 */
/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 붉게 · 테두리는 묶음 바깥 한 줄(259-3) */
.b06-detail__qtable [data-key] {
cursor: pointer;
}
@@ -92,12 +94,23 @@
.b06-detail__qtable .is-picked {
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
box-shadow: inset 0 0 0 1px var(--color-danger);
color: var(--color-danger);
}
.b06-detail__qtable tr:has(.is-picked) {
background: color-mix(in srgb, var(--color-danger) 5%, transparent);
.b06-detail__pick-outline {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.b06-detail__pick-outline path {
fill: none;
stroke: var(--color-danger);
stroke-width: 2;
stroke-linecap: square;
}
/* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */
@@ -188,6 +201,22 @@
stroke-width: 6;
}
/* 닫힌 영역 판정 면(259-2) — 투명 채움으로 누름 · 고른 영역 = 붉은 채움 */
.b06-detail__face {
fill: transparent;
stroke: none;
pointer-events: fill;
cursor: pointer;
}
.b06-detail__face:hover {
fill: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.b06-detail__face.is-selected {
fill: color-mix(in srgb, var(--color-danger) 22%, transparent);
}
/* 고른 것 값(카드 179-1 꼴) · [칸에 넣기] — [+ − ⤢] 왼쪽 */
.b06-detail__commit {
width: auto;