Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
This commit is contained in:
@@ -234,6 +234,7 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number)
|
|||||||
frozen: 1,
|
frozen: 1,
|
||||||
total: totalRow(table),
|
total: totalRow(table),
|
||||||
caption: "토적표",
|
caption: "토적표",
|
||||||
|
fillViewport: true,
|
||||||
});
|
});
|
||||||
grid.root.dataset.b08Earthwork = "table";
|
grid.root.dataset.b08Earthwork = "table";
|
||||||
host.replaceChildren(tableCard(info, grid.root));
|
host.replaceChildren(tableCard(info, grid.root));
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export function renderEarthworkSummary(host: HTMLElement, table: EarthworkTable)
|
|||||||
})),
|
})),
|
||||||
frozen: 1,
|
frozen: 1,
|
||||||
caption: "토공집계표",
|
caption: "토공집계표",
|
||||||
|
fillViewport: true,
|
||||||
});
|
});
|
||||||
host.replaceChildren(tableCard([info], grid.root));
|
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`,
|
`${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));
|
host.replaceChildren(tableCard([note(info), ...haulNote(table)], grid.root, averages));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ export async function renderMaterialSheet(
|
|||||||
frozen: built.frozen,
|
frozen: built.frozen,
|
||||||
empty: "",
|
empty: "",
|
||||||
caption: BASIS,
|
caption: BASIS,
|
||||||
|
fillViewport: true,
|
||||||
editable: (i, key) => built.editable(i, key),
|
editable: (i, key) => built.editable(i, key),
|
||||||
onEdit: (i, key, text) => {
|
onEdit: (i, key, text) => {
|
||||||
const value = parse(text);
|
const value = parse(text);
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ function buildTable(
|
|||||||
frozen: 1,
|
frozen: 1,
|
||||||
empty: "",
|
empty: "",
|
||||||
caption: L("B08_Quantity_Tab_StructureSummary"),
|
caption: L("B08_Quantity_Tab_StructureSummary"),
|
||||||
|
fillViewport: true,
|
||||||
});
|
});
|
||||||
table.root.dataset.b08Summary = "table";
|
table.root.dataset.b08Summary = "table";
|
||||||
// 줄을 다시 그려도(보이는 줄만) 떠 있는 root 하나에 걸어 둠 — 줄 번호 = 줄 `data-i`
|
// 줄을 다시 그려도(보이는 줄만) 떠 있는 root 하나에 걸어 둠 — 줄 번호 = 줄 `data-i`
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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({")
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
* 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴
|
* 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴
|
||||||
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
|
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
|
||||||
* 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로
|
* 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로
|
||||||
|
* 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0)
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import "./ui_template_data_table.css";
|
import "./ui_template_data_table.css";
|
||||||
@@ -62,6 +63,8 @@ export interface DataTableOptions {
|
|||||||
rowClass?: (index: number) => string | null | undefined;
|
rowClass?: (index: number) => string | null | undefined;
|
||||||
/** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */
|
/** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */
|
||||||
cellClass?: (index: number, key: string) => string | null | undefined;
|
cellClass?: (index: number, key: string) => string | null | undefined;
|
||||||
|
/** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */
|
||||||
|
fillViewport?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataTable {
|
export interface DataTable {
|
||||||
@@ -240,6 +243,63 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
|
|||||||
/** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */
|
/** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */
|
||||||
const OVER = 40;
|
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 {
|
export function createDataTable(opts: DataTableOptions): DataTable {
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
root.className = "ui-data-table";
|
root.className = "ui-data-table";
|
||||||
@@ -287,6 +347,7 @@ export function createDataTable(opts: DataTableOptions): DataTable {
|
|||||||
state.onRowClick?.(Number(row.dataset.i));
|
state.onRowClick?.(Number(row.dataset.i));
|
||||||
});
|
});
|
||||||
draw(true);
|
draw(true);
|
||||||
|
if (opts.fillViewport) fillViewport(root, () => draw(false));
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
setRows(rows, total) {
|
setRows(rows, total) {
|
||||||
|
|||||||
Reference in New Issue
Block a user