diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index 4345422f6..da26b1d54 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -234,6 +234,7 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number) frozen: 1, total: totalRow(table), caption: "토적표", + fillViewport: true, }); grid.root.dataset.b08Earthwork = "table"; host.replaceChildren(tableCard(info, grid.root)); diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts index ba97716f5..420100cc8 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork_Sheets.ts @@ -41,6 +41,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable) })), frozen: 1, caption: "토공집계표", + fillViewport: true, }); host.replaceChildren(tableCard([info], grid.root)); } @@ -91,7 +92,14 @@ function haulSheet( `${i + 1}) ${label} 평균운반거리: ${fmt(list.ql)} ÷ ${fmt(list.q)} = ${fmt(list.average_m)} M`, ), ); - const grid = createDataTable({ columns, rows, frozen: 1, total, caption: info }); + const grid = createDataTable({ + columns, + rows, + frozen: 1, + total, + caption: info, + fillViewport: true, + }); host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages)); } diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index f569f58be..5316e7f84 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -366,6 +366,7 @@ export async function renderMaterialSheet( frozen: built.frozen, empty: "", caption: BASIS, + fillViewport: true, editable: (i, key) => built.editable(i, key), onEdit: (i, key, text) => { const value = parse(text); diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 21cc8febf..b54932b76 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -182,6 +182,7 @@ function buildTable( frozen: 1, empty: "", caption: L("B08_Quantity_Tab_StructureSummary"), + fillViewport: true, }); table.root.dataset.b08Summary = "table"; // 줄을 다시 그려도(보이는 줄만) 떠 있는 root 하나에 걸어 둠 — 줄 번호 = 줄 `data-i` diff --git a/resources/tester/helper_ui_fill_viewport.cjs b/resources/tester/helper_ui_fill_viewport.cjs new file mode 100644 index 000000000..258c2999f --- /dev/null +++ b/resources/tester/helper_ui_fill_viewport.cjs @@ -0,0 +1,135 @@ +/* 창 아래 끝까지 칸(`ui_template/ui_template_data_table.ts` · PLAN 199-1) 단위검증 헬퍼 — + * 트랜스파일해 가짜 DOM(요소 트리 · 창 · ResizeObserver)에서 돌리고 판정할 값을 JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "ui_template", "ui_template_data_table.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; + +// ── 가짜 DOM ── +const listeners = { resize: new Set() }; +const observers = []; +const win = { + innerHeight: 900, + scrollY: 0, + addEventListener: (t, f) => listeners[t]?.add(f), + removeEventListener: (t, f) => listeners[t]?.delete(f), +}; +class RO { + constructor(fn) { + this.fn = fn; + this.targets = new Set(); + observers.push(this); + } + observe(el) { + this.targets.add(el); + } + disconnect() { + this.targets.clear(); + } +} +function el(name, css = {}, height = 0) { + const node = { + name, + css, + height, + top: 0, + parentElement: null, + nextElementSibling: null, + isConnected: false, + style: {}, + getBoundingClientRect() { + return { top: this.top - win.scrollY, height: this.height }; + }, + }; + return node; +} +function link(parent, ...kids) { + kids.forEach((k, i) => { + k.parentElement = parent; + k.nextElementSibling = kids[i + 1] ?? null; + }); + parent.kids = kids; + return parent; +} +const sty = (n) => ({ + rowGap: n.css.rowGap ?? "normal", + paddingBottom: n.css.paddingBottom ?? "0px", + borderBottomWidth: n.css.borderBottomWidth ?? "0px", + marginBottom: n.css.marginBottom ?? "0px", +}); +const doc = { body: null }; +const box = { exports: {} }; +const sandbox = { + window: win, + document: doc, + getComputedStyle: sty, + ResizeObserver: RO, +}; +new Function("exports", "module", "require", ...Object.keys(sandbox), js)( + box.exports, + box, + (p) => (p.endsWith(".css") ? {} : require(p)), + ...Object.values(sandbox), +); +const m = box.exports; + +// 문서: html > body(padding-bottom 8) > page(padding-bottom 16 · gap 8) > [tabs 40] [card(padding-bottom 12) > [root][note 40]] +const html = el("html"); +const body = el("body", { paddingBottom: "8px" }); +const page = el("page", { paddingBottom: "16px", rowGap: "8px" }); +const tabs = el("tabs", {}, 40); +const card = el("card", { paddingBottom: "12px", marginBottom: "4px" }); +const root = el("root"); +const note = el("note", {}, 40); +link(html, body); +link(body, page); +link(page, tabs, card); +link(card, root, note); +doc.body = body; +root.top = 200; +const out = { clamp: m.fillHeight(900, 200, 68), small: m.fillHeight(300, 250, 100) }; + +// 아래 자리 = note(40 + 줄 사이 0) + card 여백(12) + card 바깥 여백(4) + page 아래(16) + body 아래(8) +out.below = m.spaceBelow(root); +out.belowExpected = 40 + 0 + 12 + 4 + 16 + 8; + +// 아직 문서에 안 붙음 — 높이 안 건드림 +let fits = 0; +const stop = m.fillViewport(root, () => (fits += 1)); +out.beforeAttach = root.style.maxHeight ?? null; +out.observed = + observers.some((o) => o.targets.has(root)) && observers.some((o) => o.targets.has(body)); + +// 붙음 → 관찰자가 부름 +[html, body, page, tabs, card, root, note].forEach((n) => (n.isConnected = true)); +observers.forEach((o) => o.fn()); +out.first = root.style.maxHeight; +out.fits = fits; + +// 창 크기 바뀜 → 다시 셈 +win.innerHeight = 600; +[...listeners.resize].forEach((f) => f()); +out.afterResize = root.style.maxHeight; + +// 스크롤해도 같은 값(문서 좌표 기준) +win.scrollY = 80; +[...listeners.resize].forEach((f) => f()); +out.afterScroll = root.style.maxHeight; +win.scrollY = 0; + +// 아래 형제가 늘어남 → 그만큼 줄어듦 +note.height = 100; +observers.forEach((o) => o.fn()); +out.afterNoteGrow = root.style.maxHeight; + +// 칸이 떨어지면 멈춤 +root.isConnected = false; +[...listeners.resize].forEach((f) => f()); +out.listenersAfterDetach = listeners.resize.size; +stop(); + +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_199_1_fill_viewport.py b/resources/tester/test_199_1_fill_viewport.py new file mode 100644 index 000000000..ede12aa32 --- /dev/null +++ b/resources/tester/test_199_1_fill_viewport.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +"""PLAN 199-1 — B08 표 칸 최대 높이 = 브라우저 창 아래 끝까지. + +Node 헬퍼(`helper_ui_fill_viewport.cjs`)가 `ui_template_data_table.ts` 의 창 끝까지 셈을 가짜 DOM 에서 돌리고, +여기서 옵션 · 셈 · 창 크기 바뀜 · 합계 줄 고정 · B08 적용을 판정함. +""" + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.normpath(os.path.join(HERE, "..", "..")) + + +def _read(*parts: str) -> str: + with open(os.path.join(ROOT, *parts), encoding="utf-8") as f: + return f.read() + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_ui_fill_viewport.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_최대_높이_창_기준_셈(r): + assert r["clamp"] == 900 - 200 - 68 + assert r["small"] == 120 # 아주 작은 창에서도 머리 + 몇 줄은 남김 + assert r["below"] == r["belowExpected"] == 80 # 뒤 형제 + 윗대 아래 여백 + + +def test_붙은_뒤에_셈(r): + assert r["beforeAttach"] is None # 문서에 붙기 전엔 안 건드림 + assert r["observed"] # 칸 + 문서 본문(body) 둘 다 관찰 + assert r["first"] == f"{900 - 200 - 80}px" # 620 + assert r["fits"] >= 1 + + +def test_창_크기_바뀌면_다시_셈(r): + assert r["afterResize"] == f"{600 - 200 - 80}px" # 320 + + +def test_스크롤_위치와_무관(r): + assert r["afterScroll"] == r["afterResize"] + + +def test_아래_요소가_늘면_그만큼_줄어듦(r): + assert r["afterNoteGrow"] == f"{600 - 200 - 140}px" # 260 + + +def test_칸이_떨어지면_창_청취_해제(r): + assert r["listenersAfterDetach"] == 0 + + +def test_공용_표_옵션과_연결(): + ts = _read("ui_template", "ui_template_data_table.ts") + assert re.search(r"fillViewport\?: boolean", ts) + assert re.search(r"if \(opts\.fillViewport\) fillViewport\(root, \(\) => draw\(false\)\)", ts) + + +def test_합계_줄_고정_그대로(): + css = _read("ui_template", "ui_template_data_table.css") + assert re.search(r"tfoot[^{]*\{[^}]*position:\s*sticky[^}]*bottom:\s*0", css, re.S) + assert "max-height: calc(100vh - 260px)" in css # 옵션 안 준 표의 기본값은 그대로 + + +@pytest.mark.parametrize( + "name, count", + [ + ("B08_Quantity_UI_Summary.ts", 1), + ("B08_Quantity_UI_Earthwork.ts", 1), + ("B08_Quantity_UI_Earthwork_Sheets.ts", 2), + ("B08_Quantity_UI_Material.ts", 1), + ], +) +def test_B08_표_모두_적용(name, count): + src = _read("B08_Quantity", name) + assert src.count("fillViewport: true") == count == src.count("createDataTable({") diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index 13e7a3b54..33656ca10 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -10,6 +10,7 @@ * 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴 * 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조) * 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로 + * 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0) * ========================================================================== */ import "./ui_template_data_table.css"; @@ -62,6 +63,8 @@ export interface DataTableOptions { rowClass?: (index: number) => string | null | undefined; /** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */ cellClass?: (index: number, key: string) => string | null | undefined; + /** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */ + fillViewport?: boolean; } export interface DataTable { @@ -240,6 +243,63 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin /** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */ const OVER = 40; +/* ── 창 아래 끝까지(PLAN 199) — 칸 최대 높이 = 창 높이 − 칸 위쪽 자리 − 아래 자리 ──────────── + * 아래 자리 = 칸 뒤 형제 요소 높이 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백. + * 줄이 적으면 내용만큼 · 창 크기 · 위아래 요소가 바뀌면 다시 셈 · 칸이 문서에서 떨어지면 스스로 멈춤. + * ------------------------------------------------------------------------------------------ */ + +/** 칸이 이보다 작아지지 않게(아주 작은 창에서 머리만 남는 것 방지) */ +const MIN_HEIGHT = 120; + +/** 창 기준 최대 높이(px) — `top` = 문서 맨 위에서 칸 위까지 · `below` = 칸 아래로 남겨야 할 자리 */ +export function fillHeight(viewport: number, top: number, below: number): number { + return Math.max(MIN_HEIGHT, Math.floor(viewport - top - below)); +} + +const px = (v: string): number => parseFloat(v) || 0; + +/** 칸 아래로 문서 끝까지 차지하는 자리 */ +export function spaceBelow(el: HTMLElement): number { + let sum = 0; + for (let node: HTMLElement = el; node.parentElement; node = node.parentElement) { + const parent = node.parentElement; + const own = getComputedStyle(node); + const around = getComputedStyle(parent); + const gap = px(around.rowGap); + for (let s = node.nextElementSibling; s; s = s.nextElementSibling) { + const h = s.getBoundingClientRect().height; + if (h) sum += h + gap; + } + sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth); + } + return sum; +} + +export function fillViewport(el: HTMLElement, onFit?: () => void): () => void { + let seen = false; // 한 번이라도 문서에 붙었는지 — 붙었다가 떨어지면 멈춤 + let observer: ResizeObserver | null = null; + const fit = (): void => { + if (!el.isConnected) { + if (seen) stop(); + return; + } + seen = true; + const top = el.getBoundingClientRect().top + window.scrollY; + el.style.maxHeight = `${fillHeight(window.innerHeight, top, spaceBelow(el))}px`; + onFit?.(); + }; + const stop = (): void => { + window.removeEventListener("resize", fit); + observer?.disconnect(); + }; + window.addEventListener("resize", fit); + // 칸이 붙을 때 · 줄이 늘 때 · 위아래 요소가 바뀔 때(문서 높이) 다시 셈 + observer = new ResizeObserver(fit); + observer.observe(el); + observer.observe(document.body); + return stop; +} + export function createDataTable(opts: DataTableOptions): DataTable { const root = document.createElement("div"); root.className = "ui-data-table"; @@ -287,6 +347,7 @@ export function createDataTable(opts: DataTableOptions): DataTable { state.onRowClick?.(Number(row.dataset.i)); }); draw(true); + if (opts.fillViewport) fillViewport(root, () => draw(false)); return { root, setRows(rows, total) {