Files
Aislo/B06_Section/B06_Section_UI_Cross_Detail.ts
T

485 lines
20 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_Detail.ts
* 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4).
*
* 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 ·
* 칸 크기가 바뀌면 다시 그림) ·
* 가운데 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름) ·
* 칸 우클릭 = 그래프에서 고르기 · 자동값으로 · 행 · 열 더함 / 지움(179-4 · `_Cross_Detail_Table`).
* 아래 = 도면 표 위 빈 CAD(260 · `_Cross_Detail_Cad` · `design.structure_note_cad` · 세션 초안 →
* [저장]이 patch 로 내보냄 · B07 횡단도 수량표 위에 좌상 맞춤 · 옛 글은 처음 열 때 옮김) ·
* 머리 한 줄 접기(265-1 · 구조물 측점 폄 · 일반 측점 접힘).
* 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2).
* 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
* 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3).
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
* ========================================================================== */
import "./B06_Section_UI_Style_Cross_Detail.css";
import { openModal } from "@ui/ui_template_modal";
import { readState } 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,
outlinePicked,
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";
import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table";
import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
import {
loadNoteThings,
mountNoteCad,
stationNoLabel,
thingsAt,
} from "./B06_Section_UI_Cross_Detail_Cad";
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
export interface CrossDetailHost {
projectId: string;
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션 + 처음 배율(173-4 · 179-3 · 266-1 · 뷰 `detailChart`). */
chart: (
stationId: string,
widthPx: number,
heightPx: number,
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null;
}
let host: CrossDetailHost | null = null;
export function setCrossDetailHost(next: CrossDetailHost): void {
host = next;
}
/* ── 옛 구조물 글 세션 초안(측점키 0.01 m → 글 · 새 글은 CAD 칸 260) ─────────────── */
/** [저장]이 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)]));
}
/* ── 표(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;
}
/** 더한 칸(179-4 · `r:` 행 · `c:` 열) — 값 없음 = 빈칸 · 글 손질. */
const isAdded = (key: string): boolean => key.startsWith("r:") || key.startsWith("c:");
/** 손질 표시(179-4) — 값 칸 키 · 본문 줄 · 더한 열 · 글 손질 칸(처음 글 = `data-shown`). */
function mark(
el: HTMLElement,
data: Record<string, string | undefined>,
editable: boolean,
): HTMLElement {
for (const [name, value] of Object.entries(data))
if (value !== undefined) el.dataset[name] = value;
if (editable) {
el.contentEditable = "plaintext-only";
el.dataset.shown = el.textContent ?? "";
}
return el;
}
/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우)
* + 더한 행 · 오른쪽 끝 더한 열(179-4 · 서버 배치 그대로). */
export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement {
const { layout, text } = sheet;
const repick = new Set(sheet.repick ?? []);
const columns = layout.columns ?? [];
const rowIds = layout.rows ?? layout.left.map((_, index) => `b${index}`);
const value = (key: string | null): string =>
!key ? "" : repick.has(key) ? "다시 고름" : (text[key] ?? (isAdded(key) ? "" : "-"));
const valueCell = (key: string | null, row?: string, col?: string): HTMLElement => {
const td = cell("td", value(key), undefined, key);
if (!key) return td;
if (repick.has(key)) td.dataset.repick = "";
return mark(td, { cell: key, row, col }, isAdded(key));
};
// 이름 칸 — 더한 행이면 글 손질(자리 = 좌 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,
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();
titleRow.append(cell("th", title, { col: 8 + columns.length }));
const headRow = table.insertRow();
for (const [label, key] of layout.header)
headRow.append(cell("th", label, undefined, key), valueCell(key));
for (const column of columns)
headRow.append(mark(cell("th", column.label), { col: column.id, colLabel: column.id }, true));
layout.left.forEach(([groupL, subL, keyL], index) => {
const id = rowIds[index];
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,
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,
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));
const [labelR, keyR] = layout.right[index];
row.append(nameCell(labelR, undefined, keyR, id, 2), valueCell(keyR, id));
for (const column of columns) row.append(valueCell(column.keys[index], id, column.id));
});
return table;
}
/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */
/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 ·
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴) ·
* 처음 · 원복 = `homeScale`(266-1 · 가로 = 카드 반폭 · 줄이면 칸 맞춤 전체). */
export function attachDetailZoom(
clone: SVGSVGElement,
axes: CrossAxesOptions | null,
initial: ZoomPanState | undefined,
onChange: (state: ZoomPanState) => void,
homeScale = 1,
): ZoomPanHandle | null {
const layer = clone.querySelector<SVGGElement>("g.b06-chart__plot-layer");
if (!layer) return null;
// 칸 = viewBox(칸 크기로 그림 · 173-4) → 커서 고정 · 팬 속도 정확
const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
const axisLayer = clone.querySelector<SVGGElement>("g.b06-chart__axes");
const renderAxes = axes && axisLayer ? createCrossAxes(axisLayer, axes) : null;
const marker = layer.querySelector<SVGGElement>("g.b06-chart__center");
const markerLine = marker?.querySelector("line");
const cx = Number(markerLine?.getAttribute("x1"));
const cy = Number(markerLine?.getAttribute("y1")) + 18;
const follow = (state: ZoomPanState): void => {
renderAxes?.(state);
marker?.setAttribute(
"transform",
`translate(${cx} ${cy}) scale(${1 / Math.max(state.scale, 1e-6)}) translate(${-cx} ${-cy})`,
);
};
follow(initial ?? IDENTITY_VIEW);
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,
homeScale,
onChange: (state) => {
follow(state);
onChange(state);
},
});
}
/* ── 모달 ───────────────────────────────────────────────────────────── */
interface OpenDetail {
stationId: string;
section: CrossSection;
paint: () => void;
loadTable: () => void;
close: () => void;
stop: () => void;
}
let current: OpenDetail | null = null;
let tableTimer = 0;
/** 모달 그림을 그리는 동안 — 카드 그리기가 부르는 `refreshCrossDetail` 이 다시 그리기를 걸지 않게. */
let drawing = false;
/** 칸 크기로 새로 그림 → 복제(듣개 없음) · clip id 를 카드와 가름(같은 id 면 카드 clip 을 씀). */
function drawChart(
section: CrossSection,
widthPx: number,
heightPx: number,
): { clone: SVGSVGElement; axes: CrossAxesOptions | null; homeScale: number } | null {
drawing = true;
try {
const drawn = host?.chart(section.station_id, widthPx, heightPx);
if (!drawn) return null;
const clone = drawn.svg.cloneNode(true) as SVGSVGElement;
clone.querySelectorAll("clipPath[id]").forEach((clip) => {
const id = clip.id;
clip.id = `b06-detail-${id}`;
clone
.querySelectorAll(`[clip-path="url(#${id})"]`)
.forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`));
});
return { clone, axes: drawn.axes, homeScale: drawn.homeScale };
} finally {
drawing = false;
}
}
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) {
// 표 위 빈 CAD(260-2) — 높이 고정 · 닫으면 destroy · 머리 한 줄 접기(265-1 · 공용 접기 꼴) ·
// 열 때 = 구조물 측점 폄 · 일반 측점 접힘(그 창에서만) · 처음 펼 때 한 번 만듦(접힌 동안 칸 ·
// 초안 · [저장] 그대로 — 안 연 칸은 B07 이 같은 짝으로 그림)
const noteBox = document.createElement("section");
noteBox.className = "b06-detail__notebox ui-collapsible";
const noteHead = document.createElement("div");
noteHead.className = "b06-detail__notehead ui-collapsible__title";
noteHead.textContent = "표 위 CAD";
const note = document.createElement("div");
note.className = "b06-detail__note";
note.style.height = "180px";
noteBox.append(noteHead, note);
const routeId = host?.routeId();
const stations = host?.detail()?.longitudinal.stations ?? [];
let noteCad: { destroy: () => void } | null = null;
const openNote = (): void => {
if (noteCad || !host || routeId === null || routeId === undefined) return;
noteCad = mountNoteCad(note, section, () => state.section, {
projectId: host.projectId,
routeId,
station: stationNoLabel(section.chainage_m, inferStationInterval(stations)),
things,
});
note.querySelector<HTMLElement>(".cad-host")?.style.setProperty("min-height", "0");
};
let touched = false;
noteHead.addEventListener("click", () => {
touched = true;
if (!noteBox.classList.toggle("is-collapsed")) openNote();
});
// 접힘으로 시작 → 그 측점에 놓인 구조물이 있으면 폄(사용자가 먼저 누르면 그대로)
noteBox.classList.add("is-collapsed");
const things = host ? loadNoteThings(host.projectId) : Promise.resolve([]);
void things.then((list) => {
if (touched || current !== state || !thingsAt(list, section.chainage_m).length) return;
noteBox.classList.remove("is-collapsed");
openNote();
});
const cad = document.createElement("div");
cad.className = "b06-detail__cad";
const tableBox = document.createElement("div");
tableBox.className = "b06-detail__table";
// 차례 = 그림 → 표 → CAD 칸(265-1)
body.append(cad, tableBox, noteBox);
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(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>();
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),
);
});
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], [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)
let zoom: ZoomPanState | undefined;
let axes: CrossAxesOptions | null = null;
let drawnSize = "";
const resize = new ResizeObserver(() => state.paint());
resize.observe(cad);
const state: OpenDetail = {
stationId: section.station_id,
section,
paint() {
const { clientWidth: width, clientHeight: height } = cad;
if (width < 1 || height < 1) return;
const size = `${width}x${height}`;
if (size !== drawnSize) zoom = undefined;
drawnSize = size;
const drawn = drawChart(state.section, width, height);
if (!drawn) cad.replaceChildren(document.createTextNode("그림 없음"));
else {
const { clone } = drawn;
axes = drawn.axes;
const handle = attachDetailZoom(
clone,
axes,
zoom,
(next) => (zoom = next),
drawn.homeScale,
);
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
tablePick.redraw(); // 선 조각 고르기(209-1)
}
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,
designForTable(state.section, projectId, routeId),
)
.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();
},
stop: () => {
resize.disconnect();
tablePick.detach();
noteCad?.destroy();
},
};
// 표 손질(179-4) — 우클릭 · 그래프 고르기 · 더한 칸 글 → 세션 초안 → 표 다시 받음
const tablePick = attachDetailTable({
tableBox,
cad,
section: () => state.section,
projectId: host?.projectId,
routeId: () => host?.routeId(),
axes: () => axes,
changed: () => state.loadTable(),
});
current = state;
tableBox.textContent = "수량표 불러오는 중…";
state.paint();
state.loadTable();
},
// 닫기 단추 · Esc 도 같은 정리를 거침
beforeClose: () => {
forget();
return true;
},
});
}
/** 열린 모달 기억을 지움(닫힐 때). */
function forget(): void {
window.clearTimeout(tableTimer);
current?.stop();
current = null;
}
/**
* 카드를 다시 그릴 때마다 부름(`buildDesignControls`) — 그 측점 모달이 열려 있으면 새 카드로
* 그림을 다시 뜨고 표는 조작이 멈춘 뒤 한 번 다시 받음(최신 응답만).
*/
export function refreshCrossDetail(section: CrossSection): void {
const open = current;
if (drawing || !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);
}