Files
Aislo/B06_Section/B06_Section_UI_Cross_Detail.ts
T
eomsangdonandClaude Opus 5.5 b400993b82 feat(b06): [상세] 그림 휠 줌 · 가운데 단추 팬 · 표 강조 붉은 한 벌(164-1 · 164-2)
- 복제 자식을 g 하나로 감싸 공용 b_svg_zoom_pan(wheelZoom · 커서 고정 · 상한 16 · 1배 팬) · [+ − ⤢] · 칸 비율 = viewBox
- 강조 띠 · 판정 선 · 십자 · 사토장 모두 --color-danger · 고른 칸 붉은 테두리 · 줄 표시 · hover 옅게
- 그림에 대상 없는 칸 data-empty 회색 · 누름 없음(presentKeys) · 이름 칸도 그 줄 첫 키로 눌림
- 시험 test_164_detail_zoom 새로 · test_150_detail_pick presentKeys 사례

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-10 00:01:27 +09:00

291 lines
12 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_Detail.ts
* 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4).
*
* 위 = 카드 그림 복제(어두운 바탕 · CAD 꼴 · 남은 높이를 채움) ·
* 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 ·
* B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) ·
* 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름).
* 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2).
* 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
* 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임.
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
* ========================================================================== */
import "./B06_Section_UI_Style_Cross_Detail.css";
import { openModal } from "@ui/ui_template_modal";
import { readState, writeState } from "../A00_Common/b_page_state";
import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch";
import type {
CrossQuantitySheet,
CrossSection,
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 {
attachSvgZoomPan,
type ZoomPanHandle,
type ZoomPanState,
} from "../A00_Common/b_svg_zoom_pan";
import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
export interface CrossDetailHost {
projectId: string;
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
}
let host: CrossDetailHost | null = null;
export function setCrossDetailHost(next: CrossDetailHost): void {
host = next;
}
/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */
const noteKey = (chainageM: number): string => chainageM.toFixed(2);
/** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */
export function readStructureNotes(projectId: string, routeId: number): Map<number, string> {
const raw = readState<Record<string, string>>("structure-notes", projectId, routeId) ?? {};
return new Map(Object.entries(raw).map(([key, text]) => [Number(key), String(text)]));
}
function noteOf(section: CrossSection): string {
const routeId = host?.routeId();
if (host && routeId !== null && routeId !== undefined) {
const draft = readState<Record<string, string>>("structure-notes", host.projectId, routeId);
const text = draft?.[noteKey(section.chainage_m)];
if (text !== undefined) return text;
}
return section.design?.structure_note ?? "";
}
function writeNote(section: CrossSection, text: string): void {
// 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음
if (section.design) section.design.structure_note = text;
const routeId = host?.routeId();
if (!host || routeId === null || routeId === undefined) return;
const all = readState<Record<string, string>>("structure-notes", host.projectId, routeId) ?? {};
all[noteKey(section.chainage_m)] = text;
writeState("structure-notes", all, host.projectId, routeId);
}
/* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */
function cell(
tag: "th" | "td",
text: string,
span?: { col?: number; row?: number },
key?: string | null,
): HTMLElement {
const element = document.createElement(tag);
element.textContent = text;
if (span?.col) element.setAttribute("colspan", String(span.col));
if (span?.row) element.setAttribute("rowspan", String(span.row));
// 그림 짝이 있는 칸만 누름(150-4 · `_Cross_Detail_Pick`)
if (key && DETAIL_TARGETS[key]) element.dataset.key = key;
return element;
}
/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우). */
export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement {
const { layout, text } = sheet;
const value = (key: string | null): string => (key ? (text[key] ?? "-") : "");
const table = document.createElement("table");
table.className = "b06-detail__qtable";
const titleRow = table.insertRow();
titleRow.append(cell("th", title, { col: 8 }));
const headRow = table.insertRow();
for (const [label, key] of layout.header)
headRow.append(cell("th", label, undefined, key), cell("td", value(key), undefined, key));
layout.left.forEach(([groupL, subL, keyL], index) => {
const row = table.insertRow();
// 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(164-2)
if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }, keyL));
else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }, keyL));
if (subL !== null) row.append(cell("th", subL, undefined, keyL));
row.append(cell("td", value(keyL), undefined, keyL));
const [groupM, subM, keyM] = layout.middle[index];
if (groupM !== null) row.append(cell("th", groupM, { row: 2 }, keyM));
row.append(cell("th", subM, undefined, keyM), cell("td", value(keyM), undefined, keyM));
const [labelR, keyR] = layout.right[index];
row.append(cell("th", labelR ?? "", undefined, keyR), cell("td", value(keyR), undefined, keyR));
});
return table;
}
/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */
/** 복제 자식을 g 하나로 감싸 공용 줌 · 팬을 검(축 · 눈금째 한 장) · 휠 = 커서 고정 줌. */
export function attachDetailZoom(
clone: SVGSVGElement,
initial: ZoomPanState | undefined,
onChange: (state: ZoomPanState) => void,
): ZoomPanHandle {
const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
const layer = document.createElementNS("http://www.w3.org/2000/svg", "g");
layer.append(...clone.childNodes);
clone.append(layer);
// 칸 비율 = viewBox 비율 → 커서 고정 · 팬 속도 정확(공용 조각이 칸 크기로 환산)
clone.style.setProperty("--ratio", String(width / height));
return attachSvgZoomPan({
svg: clone,
layer,
widthPx: width,
heightPx: height,
pad: CROSS_PAD,
maxScale: 16,
// 1배에서도 팬 — 그림 둘레 한 장씩 열어 둠
content: { x: -width, y: -height, width: 3 * width, height: 3 * height },
wheelZoom: true,
initial,
onChange,
});
}
/* ── 모달 ───────────────────────────────────────────────────────────── */
interface OpenDetail {
stationId: string;
section: CrossSection;
paint: () => void;
loadTable: () => void;
close: () => void;
}
let current: OpenDetail | null = null;
let tableTimer = 0;
const cardOf = (section: CrossSection): HTMLElement | null =>
document.getElementById(`cross-${section.station_id}`);
function titleOf(section: CrossSection): string {
const stations = host?.detail()?.longitudinal.stations ?? [];
return stationLabel(section.chainage_m, inferStationInterval(stations));
}
/** [상세] — 이미 열린 것은 닫고 이 측점으로 엶. */
export function openCrossDetail(section: CrossSection): void {
current?.close();
const title = titleOf(section);
let tableSeq = 0;
openModal({
title: `${title} 상세`,
closeLabel: "닫기",
dialogClass: "b06-detail",
wide: true,
mount(body, close) {
const note = document.createElement("textarea");
note.className = "ui-input b06-detail__note";
note.rows = 3;
note.placeholder = "도면 표 위 글 — B07 횡단도 수량표 바로 위에 줄마다 찍힘";
note.value = noteOf(section);
note.addEventListener("input", () => writeNote(state.section, note.value));
const cad = document.createElement("div");
cad.className = "b06-detail__cad";
const tableBox = document.createElement("div");
tableBox.className = "b06-detail__table";
body.append(cad, note, tableBox);
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4) · 대상 없는 칸 = data-empty(164-2)
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 ?? ""));
});
if (svg) highlightDetail(svg, state.section.design, picked);
};
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;
picked = picked === key ? null : key;
showPicked();
});
// 모달 배율 — 다시 떠도(재복제) 이어 감
let zoom: ZoomPanState | undefined;
const state: OpenDetail = {
stationId: section.station_id,
section,
paint() {
const card = cardOf(state.section);
const svg = card?.querySelector("svg.b06-section__chart");
if (!svg) cad.replaceChildren(document.createTextNode("그림 없음"));
else {
const clone = svg.cloneNode(true) as SVGSVGElement;
const handle = attachDetailZoom(clone, zoom, (next) => (zoom = next));
cad.replaceChildren(clone, buildZoomControls(handle));
}
showPicked();
},
loadTable() {
const projectId = host?.projectId;
const routeId = host?.routeId();
if (!projectId || routeId === null || routeId === undefined) {
tableBox.textContent = "노선 정보 없음";
return;
}
const seq = ++tableSeq;
tableBox.classList.add("is-loading");
fetchCrossQuantityTable(
projectId,
routeId,
state.section.chainage_m,
state.section.design,
)
.then((sheet) => {
if (seq !== tableSeq || current !== state) return;
tableBox.replaceChildren(buildQuantityTable(sheet, title));
showPicked();
})
.catch((error: unknown) => {
if (seq !== tableSeq || current !== state) return;
tableBox.textContent = `수량표를 못 받음 — ${error instanceof Error ? error.message : error}`;
})
.finally(() => seq === tableSeq && tableBox.classList.remove("is-loading"));
},
close() {
forget();
close();
},
};
current = state;
tableBox.textContent = "수량표 불러오는 중…";
state.paint();
state.loadTable();
},
// 닫기 단추 · Esc 도 같은 정리를 거침
beforeClose: () => {
forget();
return true;
},
});
}
/** 열린 모달 기억을 지움(닫힐 때). */
function forget(): void {
window.clearTimeout(tableTimer);
current = null;
}
/**
* 카드를 다시 그릴 때마다 부름(`buildDesignControls`) — 그 측점 모달이 열려 있으면 새 카드로
* 그림을 다시 뜨고 표는 조작이 멈춘 뒤 한 번 다시 받음(최신 응답만).
*/
export function refreshCrossDetail(section: CrossSection): void {
const open = current;
if (!open || open.stationId !== section.station_id) return;
open.section = section;
requestAnimationFrame(() => current === open && open.paint());
window.clearTimeout(tableTimer);
tableTimer = window.setTimeout(() => current === open && open.loadTable(), 400);
}