feat(b06): 면적표 열 연동 · [상세] 그림 테마 색 · 칸 가득(173-3 · 173-4)
- 면적표 글자 = 그림 칸 폭 비례(clamp · cqw) · 표 높이 변수 같은 식 · 4열부터 「계」만(그리드 data-columns) - [상세] 그림 = 테마 토큰(어두운 고정색 지움) · 카드 그리기 함수로 모달 칸 크기에 맞춰 새로 그림(fitPlotBase · 빈 띠 없음) - 칸 크기 바뀌면 다시 그림(ResizeObserver) · clip id 카드와 가름 · 그리는 동안 refresh 되먹임 막음 - 시험 test_173_areas_detail 새로 · test_164 · test_150 단정 고침 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
@@ -2,7 +2,8 @@
|
||||
* B06_Section_UI_Cross_Detail.ts
|
||||
* 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4).
|
||||
*
|
||||
* 위 = 카드 그림 복제(어두운 바탕 · CAD 꼴 · 남은 높이를 채움) ·
|
||||
* 위 = 카드 그리기 함수로 칸 크기에 맞춰 새로 그린 그림의 복제(173-4 · 테마 색 · 칸 가득 ·
|
||||
* 칸 크기가 바뀌면 다시 그림) ·
|
||||
* 가운데 = 도면 표 위 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄 ·
|
||||
* B07 횡단도 수량표 바로 위에 줄마다 찍힘 — `_Engine_Cad_Table.cross_note_lines`) ·
|
||||
* 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름).
|
||||
@@ -35,6 +36,8 @@ export interface CrossDetailHost {
|
||||
projectId: string;
|
||||
routeId: () => number | null;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
/** 칸 크기(W × H px)로 새로 그린 카드 svg(173-4 · 뷰 `detailChart`). */
|
||||
chart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null;
|
||||
}
|
||||
|
||||
let host: CrossDetailHost | null = null;
|
||||
@@ -127,9 +130,8 @@ export function attachDetailZoom(
|
||||
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);
|
||||
// 칸 = viewBox(칸 크기로 그림 · 173-4) → 커서 고정 · 팬 속도 정확
|
||||
clone.append(layer);
|
||||
// 칸 비율 = viewBox 비율 → 커서 고정 · 팬 속도 정확(공용 조각이 칸 크기로 환산)
|
||||
clone.style.setProperty("--ratio", String(width / height));
|
||||
return attachSvgZoomPan({
|
||||
svg: clone,
|
||||
layer,
|
||||
@@ -153,13 +155,33 @@ interface OpenDetail {
|
||||
paint: () => void;
|
||||
loadTable: () => void;
|
||||
close: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
let current: OpenDetail | null = null;
|
||||
let tableTimer = 0;
|
||||
/** 모달 그림을 그리는 동안 — 카드 그리기가 부르는 `refreshCrossDetail` 이 다시 그리기를 걸지 않게. */
|
||||
let drawing = false;
|
||||
|
||||
const cardOf = (section: CrossSection): HTMLElement | null =>
|
||||
document.getElementById(`cross-${section.station_id}`);
|
||||
/** 칸 크기로 새로 그림 → 복제(듣개 없음) · clip id 를 카드와 가름(같은 id 면 카드 clip 을 씀). */
|
||||
function drawChart(section: CrossSection, widthPx: number, heightPx: number): SVGSVGElement | null {
|
||||
drawing = true;
|
||||
try {
|
||||
const svg = host?.chart(section.station_id, widthPx, heightPx);
|
||||
if (!svg) return null;
|
||||
const clone = 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;
|
||||
} finally {
|
||||
drawing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function titleOf(section: CrossSection): string {
|
||||
const stations = host?.detail()?.longitudinal.stations ?? [];
|
||||
@@ -209,18 +231,24 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
showPicked();
|
||||
});
|
||||
|
||||
// 모달 배율 — 다시 떠도(재복제) 이어 감
|
||||
// 모달 배율 — 다시 그려도 이어 감 · 칸 크기가 바뀌면 원복(173-4)
|
||||
let zoom: ZoomPanState | undefined;
|
||||
let drawnSize = "";
|
||||
const resize = new ResizeObserver(() => state.paint());
|
||||
resize.observe(cad);
|
||||
|
||||
const state: OpenDetail = {
|
||||
stationId: section.station_id,
|
||||
section,
|
||||
paint() {
|
||||
const card = cardOf(state.section);
|
||||
const svg = card?.querySelector("svg.b06-section__chart");
|
||||
if (!svg) cad.replaceChildren(document.createTextNode("그림 없음"));
|
||||
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 clone = drawChart(state.section, width, height);
|
||||
if (!clone) 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));
|
||||
}
|
||||
@@ -256,6 +284,7 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
forget();
|
||||
close();
|
||||
},
|
||||
stop: () => resize.disconnect(),
|
||||
};
|
||||
current = state;
|
||||
tableBox.textContent = "수량표 불러오는 중…";
|
||||
@@ -273,6 +302,7 @@ export function openCrossDetail(section: CrossSection): void {
|
||||
/** 열린 모달 기억을 지움(닫힐 때). */
|
||||
function forget(): void {
|
||||
window.clearTimeout(tableTimer);
|
||||
current?.stop();
|
||||
current = null;
|
||||
}
|
||||
|
||||
@@ -282,7 +312,7 @@ function forget(): void {
|
||||
*/
|
||||
export function refreshCrossDetail(section: CrossSection): void {
|
||||
const open = current;
|
||||
if (!open || open.stationId !== section.station_id) return;
|
||||
if (drawing || !open || open.stationId !== section.station_id) return;
|
||||
open.section = section;
|
||||
requestAnimationFrame(() => current === open && open.paint());
|
||||
window.clearTimeout(tableTimer);
|
||||
|
||||
@@ -77,6 +77,24 @@ export function naturalHeightAtWidth(
|
||||
return (base.naturalHeight - fixed) * (inner(toWidthPx) / inner(fromWidthPx)) + fixed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 칸(카드 폭 W 로 잰 기하 × 높이 H px) 가득 그리기(173-4 [상세]) — 자연 높이가 H 보다 크면 가로 범위를 좌우 같이 넓혀
|
||||
* m당 px 를 줄임(넓힌 자리 = 샘플 밖 빈 자리). 작으면 그대로(`crossPlotFromBase` 가 세로를 늘림).
|
||||
*/
|
||||
export function fitPlotBase(base: CrossPlotBase, heightPx: number): CrossPlotBase {
|
||||
const fixed = CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX;
|
||||
if (base.naturalHeight <= heightPx || heightPx <= fixed) return base;
|
||||
const k = (base.naturalHeight - fixed) / (heightPx - fixed);
|
||||
const grow = ((base.maxOffset - base.minOffset) * (k - 1)) / 2;
|
||||
return {
|
||||
...base,
|
||||
minOffset: base.minOffset - grow,
|
||||
maxOffset: base.maxOffset + grow,
|
||||
pixelsPerMeter: base.pixelsPerMeter / k,
|
||||
naturalHeight: heightPx,
|
||||
};
|
||||
}
|
||||
|
||||
/** 재 둔 기하에 행 높이만 얹는다 — 다시 계산하지 않는다. */
|
||||
export function crossPlotFromBase(base: CrossPlotBase, forcedHeightPx?: number): CrossPlotMetrics {
|
||||
const heightPx = forcedHeightPx ?? Math.max(base.naturalHeight, CROSS_HEIGHT);
|
||||
|
||||
@@ -43,8 +43,14 @@ export interface DesignSyncController {
|
||||
|
||||
export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
const { projectId } = ctx;
|
||||
// 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세
|
||||
if (projectId) setCrossDetailHost({ projectId, routeId: ctx.routeId, detail: ctx.detail });
|
||||
// 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세 · 칸 크기 그림(173-4)
|
||||
if (projectId)
|
||||
setCrossDetailHost({
|
||||
projectId,
|
||||
routeId: ctx.routeId,
|
||||
detail: ctx.detail,
|
||||
chart: (stationId, widthPx, heightPx) => ctx.view().detailChart(stationId, widthPx, heightPx),
|
||||
});
|
||||
|
||||
/**
|
||||
* 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
|
||||
|
||||
@@ -38,8 +38,10 @@ import {
|
||||
type CrossPlotBase,
|
||||
crossPlotBase,
|
||||
effectiveCardHalfWidth,
|
||||
fitPlotBase,
|
||||
naturalHeightAtWidth,
|
||||
} from "./B06_Section_UI_Cross_View_Metrics";
|
||||
import { cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import { CROSS_GRID_GAP, CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
|
||||
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
|
||||
import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
@@ -347,6 +349,7 @@ export function createSectionView(
|
||||
section: CrossSection,
|
||||
forcedHeightPx?: number,
|
||||
plotBase?: CrossPlotBase | null,
|
||||
widthPx = cachedCardWidth,
|
||||
): HTMLElement => {
|
||||
const card = createCrossSectionCard(
|
||||
section,
|
||||
@@ -355,7 +358,7 @@ export function createSectionView(
|
||||
(stationId) => selectStation(stationId, false),
|
||||
cachedStationInterval,
|
||||
currentCrossHalfWidth,
|
||||
cachedCardWidth,
|
||||
widthPx,
|
||||
forcedHeightPx,
|
||||
currentDetail ? cardDesignElevation(currentDetail, section) : undefined,
|
||||
onDesignChange,
|
||||
@@ -430,6 +433,7 @@ export function createSectionView(
|
||||
// CSS auto-fill의 자체 열 수와 JS columnCount가 어긋나면 같은 행 높이 그룹핑이 실제
|
||||
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다.
|
||||
grid.style.gridTemplateColumns = `repeat(${columnCount}, ${cachedCardWidth}px)`;
|
||||
grid.dataset.columns = String(columnCount); // 4열부터 면적표 「계」만(173-3 · CSS)
|
||||
cachedRowHeight.clear();
|
||||
if (detail.cross_sections.length) {
|
||||
// 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로.
|
||||
@@ -543,6 +547,24 @@ export function createSectionView(
|
||||
},
|
||||
refreshCard,
|
||||
refreshCards,
|
||||
detailChart(stationId, widthPx, heightPx) {
|
||||
const detail = currentDetail;
|
||||
const section = detail?.cross_sections.find((entry) => entry.station_id === stationId);
|
||||
if (!detail || !section) return null;
|
||||
const base = crossPlotBase(
|
||||
section,
|
||||
currentExaggeration,
|
||||
widthPx,
|
||||
effectiveCardHalfWidth(section, stationWidth?.widthFor(section) ?? currentCrossHalfWidth),
|
||||
designElevationAt(detail.longitudinal.design_profiles, section.chainage_m),
|
||||
);
|
||||
// 카드 줌 · 팬은 카드 몫 — 모달 그림은 1배에서 시작(카드 상태는 되돌려 둠)
|
||||
const cardZoom = cardZoomStates.get(stationId);
|
||||
cardZoomStates.delete(stationId);
|
||||
const card = buildCrossCard(section, heightPx, base && fitPlotBase(base, heightPx), widthPx);
|
||||
if (cardZoom) cardZoomStates.set(stationId, cardZoom);
|
||||
return card.querySelector<SVGSVGElement>("svg.b06-section__chart");
|
||||
},
|
||||
focusStation(stationId) {
|
||||
if (stationId === null) clearSelection();
|
||||
else if (selectedStationId !== stationId) selectStation(stationId, true);
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface SectionViewController {
|
||||
refreshCard: (chainageM: number) => void;
|
||||
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
|
||||
refreshCards: (chainages: ReadonlyArray<number>) => void;
|
||||
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null). */
|
||||
detailChart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null;
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
|
||||
focusStation: (stationId: string | null) => void;
|
||||
|
||||
@@ -10,7 +10,14 @@
|
||||
/* 절·성토 면적 오버레이(E-4) — 그래프 **좌측 상단**(축선 바로 안쪽), 배경색으로 그래프
|
||||
선과 겹쳐도 가독. 절토/성토 × EA·RR·BR·계 표라서 칩을 늘어놓을 때보다 가로 폭을 덜 먹는다.
|
||||
가운데(left:50%)에 두던 것을 좌측으로만 옮겼다 — 세로 자리는 그대로(2026-08-23 사용자 지시).
|
||||
58px = CROSS_PAD.left(Y축 눈금 띠 폭) — 표가 플롯 왼쪽 끝에 맞춰 선다. */
|
||||
58px = CROSS_PAD.left(Y축 눈금 띠 폭) — 표가 플롯 왼쪽 끝에 맞춰 선다.
|
||||
글자 = 그림 칸(컨테이너) 폭 비례(173-3 · 3열 ≈ 0.72rem) · 표 높이 변수도 같은 식 —
|
||||
조정창 · 보라 치수가 표 아래에서 시작(`_Cross_Controls`). */
|
||||
.b06-cross-card__chart-wrap {
|
||||
--b06-areas-font: clamp(0.6rem, 0.55rem + 0.7cqw, 0.9rem);
|
||||
--b06-areas-height: calc(var(--b06-areas-font) * 5);
|
||||
}
|
||||
|
||||
.b06-cross-card__areas {
|
||||
position: absolute;
|
||||
top: var(--spacing-8);
|
||||
@@ -19,7 +26,7 @@
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-inputs);
|
||||
background: color-mix(in srgb, var(--color-surface) 88%, transparent);
|
||||
font-size: 0.72rem;
|
||||
font-size: var(--b06-areas-font);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
border-collapse: collapse;
|
||||
@@ -28,11 +35,18 @@
|
||||
|
||||
.b06-cross-card__areas th,
|
||||
.b06-cross-card__areas td {
|
||||
padding: 0 5px;
|
||||
padding: 0 0.45em;
|
||||
text-align: center;
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
/* 4열부터 「계」만(173-3) — EA · RR · BR 칸(머리 · 본문 2~4째)을 접음 · 열 수 = 그리드 data-columns */
|
||||
.b06-section__grid:is([data-columns="4"], [data-columns="5"], [data-columns="6"])
|
||||
.b06-cross-card__areas
|
||||
:is(th, td):nth-child(n + 2):nth-child(-n + 4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 열 제목은 가운데 맞춤 — 값은 우측 정렬이라 자릿수가 흔들려도 머리글은 열 중앙에 선다. */
|
||||
.b06-cross-card__areas thead th {
|
||||
text-align: center;
|
||||
@@ -432,7 +446,7 @@
|
||||
|
||||
.b06-design__rockb-readout {
|
||||
padding: 0 var(--spacing-8);
|
||||
font-size: 0.72rem;
|
||||
font-size: var(--b06-areas-font);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-warning);
|
||||
align-self: center;
|
||||
|
||||
@@ -30,41 +30,22 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 가벼운 CAD 꼴 — 어두운 바탕 · 카드 그림 복제에 다크 색 토큰을 덮어씀 */
|
||||
/* 그림 칸 = 테마 색(173-4 · 카드와 같은 바탕) · 그림은 칸 크기로 새로 그려 가득 */
|
||||
.b06-detail__cad {
|
||||
--color-bg: #111111;
|
||||
--color-surface: #111111;
|
||||
--color-surface-raised: #1a1a1a;
|
||||
--color-border: #3a3a3a;
|
||||
--color-text: #f2f2f2;
|
||||
--color-text-body: #e6e6e6;
|
||||
--color-text-secondary: #b8b8b8;
|
||||
--color-text-muted: #8b8b8b;
|
||||
--color-primary: #c9a8ff;
|
||||
--color-warning: #ffd34d;
|
||||
--color-danger: #ff5f57;
|
||||
--color-success: #4cd37b;
|
||||
border-radius: var(--radius-cards);
|
||||
background: #111111;
|
||||
color: #f2f2f2;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* [+ − ⤢] 우상단(164-1) · 그림은 가운데 */
|
||||
/* [+ − ⤢] 우상단(164-1) */
|
||||
position: relative;
|
||||
container-type: size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 칸 비율 = viewBox 비율(--ratio · 164-1) — 칸 가득 · 여백 없이 커서 고정 줌이 정확 */
|
||||
.b06-detail__cad > svg {
|
||||
display: block;
|
||||
flex: none;
|
||||
width: min(100cqw, 100cqh * var(--ratio, 2));
|
||||
height: auto;
|
||||
aspect-ratio: var(--ratio, 2);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* B07 횡단 수량 산출표 — 칸 배치 그대로 */
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* 173-4 [상세] 그림 칸 가득 — `B06_Section_UI_Cross_View_Metrics.ts` `fitPlotBase` 를 그 자리에서 트랜스파일해 Node 로 돌림.
|
||||
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_173_areas_detail.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const B06 = path.join(__dirname, "..", "..", "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 CROSS_HEIGHT = Number(/export const CROSS_HEIGHT = (\d+)/.exec(common)[1]);
|
||||
const stub = {
|
||||
CROSS_PAD,
|
||||
CROSS_HEIGHT,
|
||||
validElevation: (s) => Number.isFinite(s.elevation_m),
|
||||
};
|
||||
const js = ts.transpileModule(
|
||||
fs.readFileSync(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts"), "utf8"),
|
||||
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } },
|
||||
).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
|
||||
const m = box.exports;
|
||||
|
||||
// 반폭 10 m · 높이 차 8 m — 넓은 칸(1200 × 400)에선 자연 높이가 칸보다 큼
|
||||
const section = {
|
||||
samples: [-10, -5, 0, 5, 10].map((offset_m, i) => ({ offset_m, elevation_m: 100 + i * 2 })),
|
||||
};
|
||||
const base = m.crossPlotBase(section, 1, 1200, 10, 104);
|
||||
const fit = m.fitPlotBase(base, 400);
|
||||
const plot = m.crossPlotFromBase(fit, 400);
|
||||
const tall = m.fitPlotBase(base, base.naturalHeight + 100);
|
||||
const out = {
|
||||
natural: base.naturalHeight,
|
||||
fitHeight: fit.naturalHeight,
|
||||
plotHeight: plot.heightPx,
|
||||
// 도면 폭(px) 그대로 = m당 px × 가로 범위
|
||||
innerBefore: base.pixelsPerMeter * (base.maxOffset - base.minOffset),
|
||||
innerAfter: fit.pixelsPerMeter * (fit.maxOffset - fit.minOffset),
|
||||
// 좌우 같이 넓힘 — 가운데 그대로
|
||||
midBefore: (base.maxOffset + base.minOffset) / 2,
|
||||
midAfter: (fit.maxOffset + fit.minOffset) / 2,
|
||||
wider: fit.maxOffset - fit.minOffset > base.maxOffset - base.minOffset,
|
||||
tallSame: tall === base,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -95,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 "aspect-ratio: var(--ratio, 2);" in css
|
||||
assert "flex: 1;\n min-height: 0;" in css and "height: 100%;" in css # 173-4 칸 가득
|
||||
|
||||
@@ -42,7 +42,7 @@ def out() -> dict:
|
||||
|
||||
def test_복제_자식을_g_하나로_감쌈_비율(out):
|
||||
assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임
|
||||
assert out["ratio"] == "2" # viewBox 600 × 300
|
||||
assert out.get("ratio") is None # 173-4 — 칸 = viewBox(칸 크기로 새로 그림) · 비율 변수 없음
|
||||
assert out["before"] is None
|
||||
|
||||
|
||||
@@ -71,9 +71,10 @@ def test_모달_배선_단추_칸_비율():
|
||||
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
|
||||
assert "position: relative;" in css
|
||||
# 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득
|
||||
assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css
|
||||
assert "--ratio" not in css and "--ratio" not in src
|
||||
|
||||
|
||||
def test_강조_붉은_한_벌_고름_hover_다름_빈칸():
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""PLAN 173-3 · 173-4 — B06 횡단 카드 면적표 열 연동 · [상세] 그림 테마 색 · 칸 가득.
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Style_Cross_Areas.css`(글자 = 그림 칸 폭 · 4열부터 「계」만) ·
|
||||
`_UI_Section_View.ts`(그리드 data-columns · `detailChart`) · `_UI_Cross_Detail.ts`(칸 크기로 새로 그림) ·
|
||||
`_UI_Style_Cross_Detail.css`(테마 토큰) · `_UI_Cross_View_Metrics.ts` `fitPlotBase`(Node 헬퍼
|
||||
`helper_173_detail_fit.cjs` 가 실제 TS 를 돌림).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
B06 = os.path.join(ROOT, "B06_Section")
|
||||
|
||||
|
||||
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 fit() -> dict:
|
||||
if not os.path.isdir(os.path.join(ROOT, "config", "node_modules")):
|
||||
pytest.skip("프론트엔드 의존성 미설치")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_173_detail_fit.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
# ── 173-3 면적표 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_면적표_글자_그림_칸_폭_비례_높이_변수_같은_식():
|
||||
css = _read("B06_Section_UI_Style_Cross_Areas.css")
|
||||
wrap = css.split(".b06-cross-card__chart-wrap {", 1)[1].split("}", 1)[0]
|
||||
assert "--b06-areas-font: clamp(0.6rem, 0.55rem + 0.7cqw, 0.9rem);" in wrap
|
||||
assert "--b06-areas-height: calc(var(--b06-areas-font) * 5);" in wrap
|
||||
table = css.split(".b06-cross-card__areas {", 1)[1].split("}", 1)[0]
|
||||
assert "font-size: var(--b06-areas-font);" in table
|
||||
assert "font-size: 0.72rem" not in css
|
||||
|
||||
# 3열 그림 칸 ≈ 400 px → 지금 글자(0.72rem)와 거의 같음 · 1열 · 6열은 끝값
|
||||
def rem(width_px: float) -> float: # 1cqw = 칸 폭 1% · 1rem = 16 px
|
||||
return min(max(0.6, 0.55 + 0.7 * width_px / 100 / 16), 0.9)
|
||||
|
||||
assert rem(400) == pytest.approx(0.725)
|
||||
assert rem(1232) == 0.9 and rem(192) == pytest.approx(0.634)
|
||||
|
||||
|
||||
def test_면적표_4열부터_계만_접기():
|
||||
css = _read("B06_Section_UI_Style_Cross_Areas.css")
|
||||
rule = re.search(
|
||||
r'\.b06-section__grid:is\(\[data-columns="4"\], \[data-columns="5"\], \[data-columns="6"\]\)\s+'
|
||||
r"\.b06-cross-card__areas\s+:is\(th, td\):nth-child\(n \+ 2\):nth-child\(-n \+ 4\) \{\n display: none;",
|
||||
css,
|
||||
)
|
||||
assert rule, "4~6열 EA · RR · BR 접기 규칙 없음"
|
||||
src = _read("B06_Section_UI_Section_View.ts")
|
||||
assert "grid.dataset.columns = String(columnCount);" in src
|
||||
|
||||
|
||||
# ── 173-4 [상세] 그림 ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_상세_테마_토큰_어두운_고정색_없음():
|
||||
css = _read("B06_Section_UI_Style_Cross_Detail.css")
|
||||
cad = css.split(".b06-detail__cad {", 1)[1].split("}", 1)[0]
|
||||
assert "background: var(--color-surface-raised);" in cad
|
||||
assert "color: var(--color-text);" in cad
|
||||
assert "--color-" not in cad.replace("var(--color-", "") # 토큰 덮어쓰기 없음
|
||||
assert "#111111" not in css and "#f2f2f2" not in css and "#1a1a1a" not in css
|
||||
|
||||
|
||||
def test_상세_칸_크기로_새로_그림_배선():
|
||||
src = _read("B06_Section_UI_Cross_Detail.ts")
|
||||
assert "const { clientWidth: width, clientHeight: height } = cad;" in src
|
||||
assert "const clone = drawChart(state.section, width, height);" in src
|
||||
assert "host?.chart(section.station_id, widthPx, heightPx)" in src
|
||||
assert "new ResizeObserver(() => state.paint())" in src and "resize.disconnect()" in src
|
||||
# 카드 clip id 와 가름 · 그리는 동안 refresh 되먹임 막음
|
||||
assert "clip.id = `b06-detail-${id}`;" in src
|
||||
assert "if (drawing || !open || open.stationId !== section.station_id) return;" in src
|
||||
assert "getElementById(`cross-" not in src # 카드 DOM 복제 지움
|
||||
view = _read("B06_Section_UI_Section_View.ts")
|
||||
assert "detailChart(stationId, widthPx, heightPx) {" in view
|
||||
assert "base && fitPlotBase(base, heightPx)" in view
|
||||
assert "cardZoomStates.delete(stationId);" in view
|
||||
types = _read("B06_Section_UI_Section_View_Types.ts")
|
||||
assert "detailChart: (stationId: string, widthPx: number, heightPx: number) =>" in types
|
||||
sync = _read("B06_Section_UI_Page_Design_Sync.ts")
|
||||
assert "ctx.view().detailChart(stationId, widthPx, heightPx)" in sync
|
||||
|
||||
|
||||
def test_칸_가득_넓은_칸은_가로_범위_넓힘(fit):
|
||||
assert fit["natural"] > 400
|
||||
assert fit["fitHeight"] == pytest.approx(400)
|
||||
assert fit["plotHeight"] == pytest.approx(400)
|
||||
assert fit["innerAfter"] == pytest.approx(fit["innerBefore"]) # 도면 폭 그대로
|
||||
assert fit["wider"] is True
|
||||
assert fit["midAfter"] == pytest.approx(fit["midBefore"]) # 좌우 같이
|
||||
|
||||
|
||||
def test_칸_가득_높은_칸은_그대로(fit):
|
||||
assert fit["tallSame"] is True # crossPlotFromBase 가 세로 범위를 늘려 채움
|
||||
|
||||
|
||||
def test_뷰_파일_700줄_안():
|
||||
for name in ("B06_Section_UI_Section_View.ts", "B06_Section_UI_Cross_Detail.ts"):
|
||||
assert len(_read(name).splitlines()) <= 700, name
|
||||
Reference in New Issue
Block a user