- 측구 줄 맨 오른쪽 [상세] · 투명 막 · 카드 안 가리는 넓은 쪽 · 카드 다시 그리면 갱신 - 구조물 글 structure_note — USER_TOUCHED_KEYS 두 벌 · CrossSectionPatch · 세션 초안 → [저장] - 표 값 = 서버 cross-quantity-table 한 길(B07 _quantity_table 을 캐시 설계로) · 칸 배치 B07 그대로 - test_138_cross_detail Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Detail.ts
|
|
* 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4).
|
|
*
|
|
* 맨 위 = 측점 구조물 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄) ·
|
|
* 가운데 = 카드 그림 복제(어두운 바탕 · CAD 꼴) · 아래 = B07 횡단 수량표(값 · 칸 배치 모두
|
|
* 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름).
|
|
* 막은 투명(누름 통과) · 자리는 그 카드를 안 가리는 넓은 쪽(좌/우 → 위/아래).
|
|
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
|
|
* ========================================================================== */
|
|
|
|
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 { inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common";
|
|
|
|
/** 페이지가 한 번 걸어 줌(`_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);
|
|
}
|
|
|
|
/* ── 자리 ───────────────────────────────────────────────────────────── */
|
|
|
|
const GAP_PX = 12;
|
|
const MIN_WIDTH_PX = 360;
|
|
const MAX_WIDTH_PX = 720;
|
|
|
|
/** 카드를 안 가리는 넓은 쪽에 붙임 — 좌우가 좁으면 위아래. */
|
|
function place(dialog: HTMLElement, card: HTMLElement | null): void {
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
const rect = card?.getBoundingClientRect();
|
|
const style = dialog.style;
|
|
style.position = "fixed";
|
|
style.margin = "0";
|
|
if (!rect) {
|
|
style.width = `${Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)}px`;
|
|
style.left = `${(vw - Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)) / 2}px`;
|
|
style.top = `${GAP_PX}px`;
|
|
style.maxHeight = `${vh - GAP_PX * 2}px`;
|
|
return;
|
|
}
|
|
const left = rect.left;
|
|
const right = vw - rect.right;
|
|
if (Math.max(left, right) >= MIN_WIDTH_PX + GAP_PX * 2) {
|
|
const width = Math.min(MAX_WIDTH_PX, Math.max(left, right) - GAP_PX * 2);
|
|
const top = Math.min(Math.max(rect.top, GAP_PX), vh - GAP_PX - 240);
|
|
style.width = `${width}px`;
|
|
style.left = `${right >= left ? rect.right + GAP_PX : rect.left - GAP_PX - width}px`;
|
|
style.top = `${Math.max(top, GAP_PX)}px`;
|
|
style.maxHeight = `${vh - Math.max(top, GAP_PX) - GAP_PX}px`;
|
|
return;
|
|
}
|
|
const width = Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2);
|
|
style.width = `${width}px`;
|
|
style.left = `${Math.min(Math.max(rect.left, GAP_PX), vw - GAP_PX - width)}px`;
|
|
const below = vh - rect.bottom;
|
|
if (below >= rect.top) {
|
|
style.top = `${rect.bottom + GAP_PX}px`;
|
|
style.maxHeight = `${Math.max(below - GAP_PX * 2, 160)}px`;
|
|
} else {
|
|
style.top = `${GAP_PX}px`;
|
|
style.maxHeight = `${Math.max(rect.top - GAP_PX * 2, 160)}px`;
|
|
}
|
|
}
|
|
|
|
/* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */
|
|
|
|
function cell(tag: "th" | "td", text: string, span?: { col?: number; row?: number }): 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));
|
|
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), cell("td", value(key)));
|
|
layout.left.forEach(([groupL, subL, keyL], index) => {
|
|
const row = table.insertRow();
|
|
if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }));
|
|
else if (groupL !== null) row.append(cell("th", groupL, { col: 2 }));
|
|
if (subL !== null) row.append(cell("th", subL));
|
|
row.append(cell("td", value(keyL)));
|
|
const [groupM, subM, keyM] = layout.middle[index];
|
|
if (groupM !== null) row.append(cell("th", groupM, { row: 2 }));
|
|
row.append(cell("th", subM), cell("td", value(keyM)));
|
|
const [labelR, keyR] = layout.right[index];
|
|
row.append(cell("th", labelR ?? ""), cell("td", value(keyR)));
|
|
});
|
|
return table;
|
|
}
|
|
|
|
/* ── 모달 ───────────────────────────────────────────────────────────── */
|
|
|
|
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",
|
|
backdropClass: "b06-detail__backdrop",
|
|
mount(body, close) {
|
|
const dialog = body.parentElement as HTMLElement;
|
|
const note = document.createElement("textarea");
|
|
note.className = "ui-input b06-detail__note";
|
|
note.rows = 3;
|
|
note.placeholder = "구조물 메모 — 이 측점에 남길 글";
|
|
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(note, cad, tableBox);
|
|
|
|
const state: OpenDetail = {
|
|
stationId: section.station_id,
|
|
section,
|
|
paint() {
|
|
const card = cardOf(state.section);
|
|
const svg = card?.querySelector("svg.b06-section__chart");
|
|
cad.replaceChildren(svg ? svg.cloneNode(true) : document.createTextNode("그림 없음"));
|
|
place(dialog, card);
|
|
},
|
|
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));
|
|
})
|
|
.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);
|
|
}
|