Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
* B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) ·
|
||||
* 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름).
|
||||
* 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2).
|
||||
* 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
|
||||
* 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임.
|
||||
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -19,8 +21,14 @@ import type {
|
||||
CrossSection,
|
||||
SectionDetailResponse,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common";
|
||||
import { DETAIL_TARGETS, highlightDetail } from "./B06_Section_UI_Cross_Detail_Pick";
|
||||
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 {
|
||||
@@ -94,12 +102,13 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
headRow.append(cell("th", label, undefined, key), cell("td", value(key), undefined, key));
|
||||
layout.left.forEach(([groupL, subL, keyL], index) => {
|
||||
const row = table.insertRow();
|
||||
if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 }));
|
||||
// 이름 칸(「깍기」 등)도 그 줄 첫 값 칸 키로 눌림(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 }));
|
||||
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));
|
||||
@@ -107,6 +116,35 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
|
||||
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 {
|
||||
@@ -151,29 +189,41 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
tableBox.className = "b06-detail__table";
|
||||
body.append(cad, note, tableBox);
|
||||
|
||||
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(150-4)
|
||||
// 표 칸 누름 = 그림 그 항목 강조 · 다시 누르면 끔(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 ?? ""));
|
||||
});
|
||||
const svg = cad.querySelector("svg");
|
||||
if (svg) highlightDetail(svg, state.section.design, picked);
|
||||
};
|
||||
tableBox.addEventListener("click", (event) => {
|
||||
const key = (event.target as HTMLElement).closest<HTMLElement>("[data-key]")?.dataset.key;
|
||||
if (!key) return;
|
||||
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");
|
||||
cad.replaceChildren(svg ? svg.cloneNode(true) : document.createTextNode("그림 없음"));
|
||||
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() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 칸 키 → 그림 요소: 면적 띠(깍기 · 쌓기) · 사토장 선 · 중심 십자(높이 넷) · 보라 판정 선 구역
|
||||
* (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려
|
||||
* `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리.
|
||||
* 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌).
|
||||
* 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖.
|
||||
* ponytail: 판정 선의 구조물 트림 여부는 복제에서 모름 → 자리로만 가름(트림 선은 놓인 구역을 따름).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -43,26 +43,35 @@ export function lineZone(design: CrossDesign, offsetM: number): LineZone {
|
||||
return ditch ? "ditch" : "cut_slope";
|
||||
}
|
||||
|
||||
/** 그림 복제 안 강조 — 묻어 온 `.is-active` 를 지우고 `key` 대상만 켬(null = 모두 끔). */
|
||||
export function highlightDetail(svg: Element, design: CrossDesign | undefined, key: string | null) {
|
||||
svg.querySelectorAll(".is-active").forEach((el) => el.classList.remove("is-active"));
|
||||
const target = key ? DETAIL_TARGETS[key] : undefined;
|
||||
if (!target) return;
|
||||
if ("selector" in target) {
|
||||
svg.querySelectorAll(target.selector).forEach((el) => el.classList.add("is-active"));
|
||||
return;
|
||||
}
|
||||
/** `key` 의 강조 대상 요소 — 그 카드에 없으면 빈 목록(164-2 `data-empty`). */
|
||||
function targetsOf(svg: Element, design: CrossDesign | undefined, key: string): Element[] {
|
||||
const target = DETAIL_TARGETS[key];
|
||||
if (!target) return [];
|
||||
if ("selector" in target) return [...svg.querySelectorAll(target.selector)];
|
||||
const center = svg.querySelector("g.b06-chart__center line");
|
||||
const ppm = Number(svg.getAttribute("data-px-per-m"));
|
||||
if (!design || !center || !(ppm > 0)) return;
|
||||
if (!design || !center || !(ppm > 0)) return [];
|
||||
const x0 = Number(center.getAttribute("x1"));
|
||||
svg.querySelectorAll(".b06-chart__design-hit").forEach((hit) => {
|
||||
return [...svg.querySelectorAll(".b06-chart__design-hit")].filter((hit) => {
|
||||
const xs = (hit.getAttribute("points") ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => Number(pair.split(",")[0]));
|
||||
const mid = (xs[0] + xs[xs.length - 1]) / 2;
|
||||
// x = 왼쪽 여백 + (최대 offset − offset) × ppm → offset = (x(0) − x) ÷ ppm
|
||||
if (lineZone(design, (x0 - mid) / ppm) === target.zone) hit.classList.add("is-active");
|
||||
return lineZone(design, (x0 - mid) / ppm) === target.zone;
|
||||
});
|
||||
}
|
||||
|
||||
/** 그림 복제 안 강조 — 묻어 온 `.is-active` 를 지우고 `key` 대상만 켬(null = 모두 끔). */
|
||||
export function highlightDetail(svg: Element, design: CrossDesign | undefined, key: string | null) {
|
||||
svg.querySelectorAll(".is-active").forEach((el) => el.classList.remove("is-active"));
|
||||
if (key) targetsOf(svg, design, key).forEach((el) => el.classList.add("is-active"));
|
||||
}
|
||||
|
||||
/** 그 카드에 강조 대상이 있는 키(164-2) — 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음)은 빠짐. */
|
||||
export function presentKeys(svg: Element, design: CrossDesign | undefined): Set<string> {
|
||||
return new Set(
|
||||
Object.keys(DETAIL_TARGETS).filter((key) => targetsOf(svg, design, key).length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,13 +50,21 @@
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* [+ − ⤢] 우상단(164-1) · 그림은 가운데 */
|
||||
position: relative;
|
||||
container-type: size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* viewBox 그대로 — 비율 유지하며 칸 가득 */
|
||||
/* 칸 비율 = viewBox 비율(--ratio · 164-1) — 칸 가득 · 여백 없이 커서 고정 줌이 정확 */
|
||||
.b06-detail__cad > svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: none;
|
||||
width: min(100cqw, 100cqh * var(--ratio, 2));
|
||||
height: auto;
|
||||
aspect-ratio: var(--ratio, 2);
|
||||
}
|
||||
|
||||
/* B07 횡단 수량 산출표 — 칸 배치 그대로 */
|
||||
@@ -89,24 +97,52 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 표 칸 누름 ↔ 그림 강조(150-4) — 짝 있는 칸만 손가락 · 고른 칸 강조 */
|
||||
/* 표 칸 누름 ↔ 그림 강조(150-4 · 164-2) — 짝 있는 칸만 손가락 · 고른 칸 · 줄 붉게 */
|
||||
.b06-detail__qtable [data-key] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-detail__qtable [data-key]:hover,
|
||||
.b06-detail__qtable [data-key]:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 8%, transparent);
|
||||
}
|
||||
|
||||
.b06-detail__qtable .is-picked {
|
||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
|
||||
box-shadow: inset 0 0 0 1px var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.b06-detail__qtable tr:has(.is-picked) {
|
||||
background: color-mix(in srgb, var(--color-danger) 5%, transparent);
|
||||
}
|
||||
|
||||
/* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */
|
||||
.b06-detail__qtable [data-key][data-empty] {
|
||||
cursor: default;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 그림 강조 한 벌 = 붉은 계통(164-2 · 카드 색은 그대로) — 띠 · 판정 선 · 중심 십자 · 사토장 */
|
||||
.b06-detail__cad .b06-chart__area.is-active > polygon {
|
||||
fill: color-mix(in srgb, var(--color-danger) 35%, transparent);
|
||||
stroke: var(--color-danger);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.b06-detail__cad .b06-chart__design-hit.is-active {
|
||||
stroke: color-mix(in srgb, var(--color-danger) 70%, transparent);
|
||||
stroke-width: 9;
|
||||
}
|
||||
|
||||
/* 그림 복제에만 있는 강조 둘 — 중심 십자(높이 넷) · 사토장 선 */
|
||||
.b06-detail__cad .b06-chart__center.is-active line {
|
||||
stroke: var(--color-warning);
|
||||
stroke: var(--color-danger);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.b06-detail__cad .b06-chart__spoil-fill.is-active {
|
||||
stroke-width: 4;
|
||||
stroke: var(--color-danger);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* 판정 선은 복제에서 누를 일 없음 — 손가락 지움 */
|
||||
|
||||
@@ -131,4 +131,12 @@ for (const key of [
|
||||
pick.highlightDetail(svg, design, key);
|
||||
out.active[String(key)] = activeOf(svg);
|
||||
}
|
||||
// 164-2 — 그 카드에 대상이 있는 키(암석 띠 · 사토장 없는 카드 = 그 칸 빠짐)
|
||||
out.present = [...pick.presentKeys(fakeSvg(20, 300), design)].sort();
|
||||
const bare = fakeSvg(20, 300);
|
||||
const kept = bare.els.filter(
|
||||
(el) => !el.set.has("b06-chart__area--cut_rock") && !el.set.has("b06-chart__spoil-fill"),
|
||||
);
|
||||
bare.els.splice(0, bare.els.length, ...kept);
|
||||
out.presentBare = [...pick.presentKeys(bare, design)].sort();
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/* PLAN 164-1 단위검증 헬퍼 — [상세] 그림 줌 · 팬(`B06_Section_UI_Cross_Detail.ts` `attachDetailZoom`).
|
||||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 가짜 svg 에 휠 · 가운데 단추 · 더블클릭을 쏨.
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_164_detail_zoom.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const B06 = path.join(ROOT, "B06_Section");
|
||||
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
|
||||
// 카드 여백은 원본에서 읽음(화면과 같은 값)
|
||||
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
|
||||
const STUBS = {
|
||||
"@ui/ui_template_modal": { openModal: () => {} },
|
||||
"../A00_Common/b_page_state": {},
|
||||
"./B06_Section_Api_Fetch": {},
|
||||
"./B06_Section_UI_Section_Common": { CROSS_PAD, L: (key) => key },
|
||||
"./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} },
|
||||
};
|
||||
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
|
||||
function loadTs(full) {
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
const localRequire = (request) => {
|
||||
if (request.endsWith(".css")) return {};
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
function el(tag) {
|
||||
const attrs = {};
|
||||
const listeners = {};
|
||||
const self = {
|
||||
tag,
|
||||
parent: null,
|
||||
childNodes: [],
|
||||
attrs,
|
||||
style: { setProperty: (name, value) => (attrs[`style:${name}`] = value) },
|
||||
classList: { add: () => {}, remove: () => {} },
|
||||
setAttribute: (name, value) => (attrs[name] = String(value)),
|
||||
getAttribute: (name) => attrs[name] ?? null,
|
||||
// 진짜 DOM 처럼 옛 부모에서 옮겨 옴
|
||||
append: (...nodes) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.parent) node.parent.childNodes.splice(node.parent.childNodes.indexOf(node), 1);
|
||||
node.parent = self;
|
||||
self.childNodes.push(node);
|
||||
}),
|
||||
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
|
||||
fire: (type, event) => {
|
||||
const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event };
|
||||
(listeners[type] ?? []).forEach((fn) => fn(full));
|
||||
return full;
|
||||
},
|
||||
setPointerCapture: () => {},
|
||||
releasePointerCapture: () => {},
|
||||
// 칸 = viewBox 의 2 배(비율 같음)
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 1200, height: 600 }),
|
||||
};
|
||||
return self;
|
||||
}
|
||||
globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: el };
|
||||
|
||||
const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts"));
|
||||
|
||||
const svg = el("svg");
|
||||
svg.setAttribute("viewBox", "0 0 600 300");
|
||||
svg.append(el("rect"), el("g"), el("g"));
|
||||
let saved;
|
||||
const handle = attachDetailZoom(svg, undefined, (next) => (saved = next));
|
||||
const layer = svg.childNodes[0];
|
||||
const out = {
|
||||
wrapped: svg.childNodes.length === 1 && layer.tag === "g" && layer.childNodes.length === 3,
|
||||
ratio: svg.attrs["style:--ratio"],
|
||||
before: layer.getAttribute("transform"),
|
||||
};
|
||||
// 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정
|
||||
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
|
||||
out.wheelIn = { ...saved };
|
||||
out.cursorFixed = saved.tx + saved.scale * 300;
|
||||
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 });
|
||||
out.wheelOut = saved.scale;
|
||||
// 원배율에서 좌클릭 끌기 = 팬 아님
|
||||
handle.reset();
|
||||
svg.fire("pointerdown", { button: 0, clientX: 100, clientY: 100 });
|
||||
svg.fire("pointermove", { clientX: 300, clientY: 100 });
|
||||
svg.fire("pointerup", {});
|
||||
out.leftDrag = { ...saved };
|
||||
// 가운데 단추 끌기 = 팬(1배에서도 · 화면 200px = svg 100)
|
||||
svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 });
|
||||
svg.fire("pointermove", { clientX: 300, clientY: 140 });
|
||||
svg.fire("pointerup", {});
|
||||
out.middlePan = { ...saved };
|
||||
out.panClickStopped = svg.fire("click", {
|
||||
stopPropagation() {
|
||||
this.stopped = true;
|
||||
},
|
||||
}).stopped;
|
||||
// 다시 떠도 배율 이어 감
|
||||
const again = el("svg");
|
||||
again.setAttribute("viewBox", "0 0 600 300");
|
||||
attachDetailZoom(again, saved, () => {});
|
||||
out.restored = again.childNodes[0].getAttribute("transform");
|
||||
// 더블클릭 원복
|
||||
svg.fire("dblclick", {});
|
||||
out.reset = layer.getAttribute("transform");
|
||||
out.max = (() => {
|
||||
for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
|
||||
return saved.scale;
|
||||
})();
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -68,6 +68,12 @@ def test_칸마다_그림_강조(out):
|
||||
assert active["fence"] == []
|
||||
|
||||
|
||||
def test_대상_있는_키만_빈칸은_빠짐(out):
|
||||
"""164-2 — 그림에 대상이 없는 칸(암석 띠 · 사토장 없음)은 `data-empty`."""
|
||||
assert out["present"] == sorted(out["targets"])
|
||||
assert set(out["present"]) - set(out["presentBare"]) == {"cut_rock", "spoil_fill"}
|
||||
|
||||
|
||||
def test_상세_표_칸_키_누름_배선():
|
||||
with open(
|
||||
os.path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Detail.ts"), encoding="utf-8"
|
||||
@@ -89,4 +95,4 @@ def test_상세_큰_창_공용_막_그림_글_표_순서():
|
||||
assert "body.append(cad, note, tableBox)" in src
|
||||
assert "pointer-events: none" not in css and "b06-detail__backdrop" not in css
|
||||
assert ".ui-modal.b06-detail > .ui-modal__body {\n overflow: hidden;" in css
|
||||
assert "flex: 1;\n min-height: 0;" in css and "height: 100%;" in css
|
||||
assert "flex: 1;\n min-height: 0;" in css and "aspect-ratio: var(--ratio, 2);" in css
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""PLAN 164 — B06 [상세] 그림 줌 · 팬(164-1) · 표 강조 붉은 한 벌(164-2).
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Cross_Detail.ts`(`attachDetailZoom` · 칸 누름) ·
|
||||
`_UI_Style_Cross_Detail.css`(칸 비율 · 붉은 강조). 실제 TS 를 `helper_164_detail_zoom.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
B06 = os.path.join(ROOT, "B06_Section")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
with open(os.path.join(B06, name), encoding="utf-8") as f:
|
||||
return f.read().replace(chr(13), "")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_164_detail_zoom.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_복제_자식을_g_하나로_감쌈_비율(out):
|
||||
assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임
|
||||
assert out["ratio"] == "2" # viewBox 600 × 300
|
||||
assert out["before"] is None
|
||||
|
||||
|
||||
def test_휠_당기면_확대_커서_고정(out):
|
||||
assert out["wheelIn"]["scale"] == pytest.approx(1.15)
|
||||
assert out["cursorFixed"] == pytest.approx(300) # 커서 아래 svg x 300 이 제자리
|
||||
assert out["wheelOut"] < out["wheelIn"]["scale"]
|
||||
assert out["max"] == 16
|
||||
|
||||
|
||||
def test_가운데_단추_팬_좌클릭은_팬_아님(out):
|
||||
assert out["leftDrag"] == {"scale": 1, "tx": 0, "ty": 0}
|
||||
# 1배에서도 팬 · 화면 200·40 px = svg 100·20(칸 2 배)
|
||||
assert out["middlePan"] == {"scale": 1, "tx": 100, "ty": 20}
|
||||
assert out["panClickStopped"] is True
|
||||
|
||||
|
||||
def test_다시_떠도_배율_유지_더블클릭_원복(out):
|
||||
assert out["restored"] == "translate(100 20) scale(1)"
|
||||
assert out["reset"] == "translate(0 0) scale(1)"
|
||||
|
||||
|
||||
def test_모달_배선_단추_칸_비율():
|
||||
src = _read("B06_Section_UI_Cross_Detail.ts")
|
||||
css = _read("B06_Section_UI_Style_Cross_Detail.css")
|
||||
assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src
|
||||
assert "cad.replaceChildren(clone, buildZoomControls(handle))" in src
|
||||
assert "attachDetailZoom(clone, zoom, (next) => (zoom = next))" in src
|
||||
assert "position: relative;" in css and "container-type: size;" in css
|
||||
assert "aspect-ratio: var(--ratio, 2);" in css
|
||||
assert "width: min(100cqw, 100cqh * var(--ratio, 2));" in css
|
||||
|
||||
|
||||
def test_강조_붉은_한_벌_고름_hover_다름_빈칸():
|
||||
css = _read("B06_Section_UI_Style_Cross_Detail.css")
|
||||
for selector in (
|
||||
".b06-detail__cad .b06-chart__area.is-active > polygon {\n fill: color-mix(in srgb, var(--color-danger)",
|
||||
".b06-detail__cad .b06-chart__design-hit.is-active {\n stroke: color-mix(in srgb, var(--color-danger)",
|
||||
".b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);",
|
||||
".b06-detail__cad .b06-chart__spoil-fill.is-active {\n stroke: var(--color-danger);",
|
||||
".b06-detail__qtable .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%",
|
||||
".b06-detail__qtable tr:has(.is-picked) {",
|
||||
".b06-detail__qtable [data-key][data-empty] {\n cursor: default;",
|
||||
):
|
||||
assert selector in css, selector
|
||||
assert "royal-amethyst" not in css and "--color-warning);" not in css.split("/* 그림 강조")[1]
|
||||
assert (
|
||||
".b06-detail__qtable [data-key]:hover {\n background: color-mix(in srgb, var(--color-danger) 8%"
|
||||
in css
|
||||
)
|
||||
|
||||
|
||||
def test_빈칸_누름_없음_이름_칸_첫_키():
|
||||
src = _read("B06_Section_UI_Cross_Detail.ts")
|
||||
assert 'el.toggleAttribute("data-empty", !present.has(el.dataset.key ?? ""))' in src
|
||||
assert 'if (!key || el.hasAttribute("data-empty")) return;' in src
|
||||
assert 'cell("th", groupL, { row: 2 }, keyL)' in src
|
||||
assert 'cell("th", groupM, { row: 2 }, keyM)' in src
|
||||
Reference in New Issue
Block a user