feat(ground): 지반·지층 card inputs, layer rows, water level, section drawing
- types/ground.ts: 위치 (육상·하상·해상), 지하수위 고려 and EL, 지층 행 (name and 하단 EL, from the top down). Checks G01 (layer order), G02 (water level range), G03 (layer name). - The 지반고 still feeds the soil cover check; the layer rows and water level are inputs and do not change the 3D model. - view/ground-svg.ts: the 삽도 drawn from the inputs and the generated footings and piles on one EL scale. - 일괄 적용 copies 위치 and 지하수위 to the checked piers (속성 복사 item, off by default). Changes list shows arrays and booleans as text. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e70cfb969
commit
16ab317094
8 files changed
+400
-6
No files matched your search
+5
-1
@@ -23,7 +23,7 @@ export const EDITABLE_FIELDS = [
|
||||
] as const;
|
||||
|
||||
/** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */
|
||||
export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows'] as const;
|
||||
export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'strataRows'] as const;
|
||||
|
||||
export type EditableKey = (typeof TRACKED_KEYS)[number];
|
||||
|
||||
@@ -44,6 +44,10 @@ export const FIELD_LABELS: Record<EditableKey, string> = {
|
||||
pileLengthMm: '말뚝 길이',
|
||||
groundEl: '지반고 EL',
|
||||
pileRows: '열별 본수',
|
||||
groundPosition: '지반 위치',
|
||||
groundWaterOn: '지하수위 고려',
|
||||
groundWaterEl: '지하수위 EL',
|
||||
strataRows: '지층 행',
|
||||
};
|
||||
|
||||
/** Keys whose values differ between two inputs. Arrays are compared by value. */
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ import type { PierInput } from './types';
|
||||
* 속성 복사 (spec §9 · plan C7). Copies chosen attributes from one pier to others.
|
||||
* Values the spec never copies (coordinates, IP, elevations, names) are not in any item, so they cannot be copied.
|
||||
*/
|
||||
export type CopyItem = 'coping-width' | 'coping-length' | 'pile-length' | 'pile-rows';
|
||||
export type CopyItem = 'coping-width' | 'coping-length' | 'pile-length' | 'pile-rows' | 'ground-position';
|
||||
|
||||
interface ItemRule {
|
||||
label: string;
|
||||
@@ -21,6 +21,7 @@ export const COPY_ITEMS: Record<CopyItem, ItemRule> = {
|
||||
'coping-length': { label: '코핑 길이 L', keys: ['copingLengthMm'], defaultOn: false, pileItem: false },
|
||||
'pile-length': { label: '말뚝 길이', keys: ['pileLengthMm'], defaultOn: false, pileItem: true },
|
||||
'pile-rows': { label: '열별 본수', keys: ['pileRows'], defaultOn: false, pileItem: true },
|
||||
'ground-position': { label: '지반 위치·지하수위', keys: ['groundPosition', 'groundWaterOn', 'groundWaterEl'], defaultOn: false, pileItem: false },
|
||||
};
|
||||
|
||||
export const DEFAULT_COPY_ITEMS: CopyItem[] = (Object.keys(COPY_ITEMS) as CopyItem[]).filter((k) => COPY_ITEMS[k].defaultOn);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Issue, PierInput } from './types';
|
||||
|
||||
/** 위치 choices of the 지반·지층 card (시안 2026-10-26). */
|
||||
export const GROUND_POSITIONS = ['육상', '하상', '해상'] as const;
|
||||
|
||||
/** 지층 names, in the order of the RAG strata list (지반모델링계획). */
|
||||
export const STRATA_NAMES = ['매립토', '퇴적토', '풍화토', '풍화암', '연암', '경암'] as const;
|
||||
|
||||
/** The 지지층 for the pile tip check (지반 모델 G-7). */
|
||||
export const SUPPORT_STRATUM = '연암';
|
||||
|
||||
/**
|
||||
* 지반·지층 입력 검사 (승인 2026-10-26).
|
||||
* G01: 층 하단 EL은 위 경계보다 낮아야 함. G02: 지하수위는 지반고와 가장 아래 층 사이에 있어야 함.
|
||||
* G03: 층 이름은 지층 목록에 있어야 함. Ground values are inputs only; they do not change the 3D model.
|
||||
*/
|
||||
export function validateGround(input: PierInput, issues: Issue[]): void {
|
||||
const rows = input.strataRows ?? [];
|
||||
let upper = input.groundEl;
|
||||
rows.forEach((row, i) => {
|
||||
const label = `지층 ${i + 1}행 (${row.name})`;
|
||||
if (!(STRATA_NAMES as readonly string[]).includes(row.name)) {
|
||||
issues.push({ code: 'G03', level: 'W', pier: input.name, message: `${label}: 층 이름이 지층 목록에 없습니다.` });
|
||||
}
|
||||
if (!(row.bottomEl < upper)) {
|
||||
issues.push({ code: 'G01', level: 'W', pier: input.name, message: `${label}: 하단 EL ${row.bottomEl.toFixed(3)}이 위 경계 ${upper.toFixed(3)}보다 낮아야 합니다.` });
|
||||
}
|
||||
upper = row.bottomEl;
|
||||
});
|
||||
if (input.groundWaterOn) {
|
||||
const w = input.groundWaterEl;
|
||||
const lowest = rows.length ? rows[rows.length - 1].bottomEl : undefined;
|
||||
if (w === undefined || !Number.isFinite(w)) {
|
||||
issues.push({ code: 'G02', level: 'W', pier: input.name, message: '지하수위를 고려하지만 지하수위 EL이 없습니다.' });
|
||||
} else if (w > input.groundEl || (lowest !== undefined && w < lowest)) {
|
||||
issues.push({ code: 'G02', level: 'W', pier: input.name, message: `지하수위 EL ${w.toFixed(3)}이 지반고와 가장 아래 층 사이에 있지 않습니다.` });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,20 @@ export interface PierInput {
|
||||
pileKind?: '강관' | 'PHC';
|
||||
/** 공통 규칙 for the solplate: centre thickness T, 솔플 여유 margin (mm), bearing height h3 (mm). */
|
||||
solplate?: { T: number; margin: number; h3: number };
|
||||
/** 지반·지층 (승인 2026-10-26): 위치, 지하수위 고려와 EL, 지층별 행 (위에서 아래, 행의 EL = 그 층의 하단 EL).
|
||||
* Absent = 육상, 지하수위 없음, 지층 행 없음. */
|
||||
groundPosition?: GroundPosition;
|
||||
groundWaterOn?: boolean;
|
||||
groundWaterEl?: number;
|
||||
strataRows?: StrataRow[];
|
||||
}
|
||||
|
||||
export type GroundPosition = '육상' | '하상' | '해상';
|
||||
|
||||
export interface StrataRow {
|
||||
name: string;
|
||||
/** 이 층의 하단 EL (m). */
|
||||
bottomEl: number;
|
||||
}
|
||||
|
||||
export type IssueLevel = 'E' | 'W' | 'I';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DIM, type PierModel } from './geometry';
|
||||
import type { Issue, PierInput, PierType } from './types';
|
||||
import { validateGround } from './ground';
|
||||
|
||||
const EXPECTED_COLUMNS: Record<PierType, number> = { 'T형': 1, '2주형': 2, '3주형': 3 };
|
||||
|
||||
@@ -89,4 +90,5 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[]
|
||||
message: `받침블럭 높이 ${(minBlock * 1000).toFixed(0)} mm가 피복 + 철근 지름(${((cover + barDia) * 1000).toFixed(0)} mm)보다 작습니다. 블럭 철근은 코핑 안에 묻힙니다.`,
|
||||
});
|
||||
}
|
||||
validateGround(input, issues);
|
||||
}
|
||||
+154
-4
@@ -17,6 +17,8 @@ import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial,
|
||||
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity';
|
||||
import { parseDrawingRows } from './core/drawing-import';
|
||||
import { groundElFor, parseGroundModel, type GroundModelFile } from './core/ground-model';
|
||||
import { GROUND_POSITIONS, STRATA_NAMES } from './core/ground';
|
||||
import { groundSectionSvg } from './view/ground-svg';
|
||||
import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table';
|
||||
import { columnDiameterIssues } from './core/column-check';
|
||||
import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec';
|
||||
@@ -37,7 +39,7 @@ import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/se
|
||||
import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options';
|
||||
import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project';
|
||||
import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup';
|
||||
import type { Issue, PierInput, PierType } from './core/types';
|
||||
import type { Issue, PierInput, PierType, StrataRow } from './core/types';
|
||||
import { buildPierScene, setRebarView, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
|
||||
|
||||
/** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */
|
||||
@@ -78,7 +80,7 @@ const MEMBERS: { id: MemberId; label: string; keys: EditableKey[]; note: string
|
||||
{ id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm', 'copingH1Mm', 'copingH2Mm', 'levelWidthMm'], note: '폭, 길이, 끝단 높이 h1, 테이퍼 높이 h2, Level 폭을 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' },
|
||||
{ id: 'column', label: '기둥', keys: ['columnDiameterMm'], note: '기둥 직경 D를 입력합니다 (1200–3000 mm, 50 mm 단위). 높이와 위치는 계산값입니다.' },
|
||||
{ id: 'footing', label: '확대기초 · 말뚝', keys: ['footingLengthMm', 'footingWidthMm', 'footingHeightMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileLengthMm'], note: '확대기초 치수, 비림, 말뚝 길이와 규격을 입력합니다. 규칙 미정 항목(모따기, 경사, 복합 말뚝)은 다음 단계에서 추가합니다.' },
|
||||
{ id: 'ground', label: '지반·지층', keys: ['groundEl'], note: '지반고는 지반 모델 값을 적용하거나 직접 입력합니다. 지층 입력은 다음 단계에서 추가합니다.' },
|
||||
{ id: 'ground', label: '지반·지층', keys: ['groundEl', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'strataRows'], note: '지반고는 계산(흙덮개)에 쓰고, 지층은 행으로, 지하수위는 직접 입력합니다. 위치는 육상·하상·해상 중 고릅니다.' },
|
||||
];
|
||||
|
||||
interface Pending {
|
||||
@@ -1498,9 +1500,157 @@ function renderBearingBasis(wrap: HTMLDivElement, pier: Pier): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button. */
|
||||
/** Text of one tracked value in the 변경 list. */
|
||||
function valueText(key: EditableKey, v: unknown): string {
|
||||
if (v === undefined || v === null) return '없음';
|
||||
if (key === 'strataRows') {
|
||||
const rows = v as StrataRow[];
|
||||
return rows.length ? rows.map((r) => `${r.name} ${r.bottomEl}`).join(' / ') : '없음';
|
||||
}
|
||||
if (typeof v === 'boolean') return v ? '예' : '아니오';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
/** Sets one 지반·지층 input in the draft (applied to the model by 다시 생성). */
|
||||
function editGround(name: string, key: 'groundPosition' | 'groundWaterOn' | 'groundWaterEl', value: unknown): void {
|
||||
const p = state.piers.find((q) => q.input.name === name);
|
||||
if (!p) return;
|
||||
if (key === 'groundWaterEl' && value !== undefined && !Number.isFinite(value)) {
|
||||
setError('지하수위 EL은 숫자로 입력하세요. 값을 되돌렸습니다.');
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
p.draft = { ...p.draft, [key]: value };
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function setStrataRows(name: string, rows: StrataRow[]): void {
|
||||
const p = state.piers.find((q) => q.input.name === name);
|
||||
if (!p) return;
|
||||
p.draft = { ...p.draft, strataRows: rows };
|
||||
renderAll();
|
||||
}
|
||||
|
||||
/** 일괄 적용: the source's 위치 and 지하수위 go to the checked piers, which are then regenerated. */
|
||||
function applyGroundPositionTo(source: Pier, targets: Pier[]): void {
|
||||
for (const target of targets) {
|
||||
target.draft = applyCopy(target.draft, planCopy(source.draft, target.draft, ['ground-position']));
|
||||
}
|
||||
regenerateAllPiers();
|
||||
renderAll();
|
||||
setStatus(`${targets.length}기에 위치와 지하수위를 적용했습니다.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
|
||||
* The ground model import and the 지반고 apply stay at the bottom, as before.
|
||||
*/
|
||||
function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
|
||||
const name = pier.input.name;
|
||||
const d = pier.draft;
|
||||
const wrap = el('div', undefined, 'ground-model');
|
||||
|
||||
wrap.appendChild(el('p', '위치', 'lbl'));
|
||||
const positions = el('div', undefined, 'opts');
|
||||
for (const v of GROUND_POSITIONS) {
|
||||
const b = el('button', v, 'btn small');
|
||||
b.type = 'button';
|
||||
b.setAttribute('aria-pressed', String((d.groundPosition ?? '육상') === v));
|
||||
b.addEventListener('click', () => editGround(name, 'groundPosition', v));
|
||||
positions.appendChild(b);
|
||||
}
|
||||
wrap.appendChild(positions);
|
||||
|
||||
wrap.appendChild(el('p', '지하수위', 'lbl'));
|
||||
const water = el('div', undefined, 'ground-water');
|
||||
const check = el('input');
|
||||
check.type = 'checkbox';
|
||||
check.checked = d.groundWaterOn === true;
|
||||
check.id = 'ground-water-on';
|
||||
check.addEventListener('change', () => editGround(name, 'groundWaterOn', check.checked));
|
||||
const checkLabel = el('label', ' 지하수위 고려');
|
||||
checkLabel.prepend(check);
|
||||
const level = el('input');
|
||||
level.type = 'number';
|
||||
level.step = 'any';
|
||||
level.id = 'ground-water-el';
|
||||
level.placeholder = '지하수위 EL (m)';
|
||||
level.value = d.groundWaterEl === undefined ? '' : String(d.groundWaterEl);
|
||||
level.disabled = !check.checked;
|
||||
level.addEventListener('change', () => editGround(name, 'groundWaterEl', level.value === '' ? undefined : Number(level.value)));
|
||||
water.append(checkLabel, level, el('span', 'm', 'muted small'));
|
||||
wrap.appendChild(water);
|
||||
|
||||
// 지층 행 (승인 2026-10-26): one row per layer, from the top down; the EL is the layer's bottom EL.
|
||||
const rows = d.strataRows ?? [];
|
||||
wrap.appendChild(el('p', `지층 행 (위에서 아래 · 하단 EL m · 지반고 ${d.groundEl.toFixed(3)} 아래)`, 'lbl'));
|
||||
const table = el('table', undefined, 'grid-table rebar-table');
|
||||
const head = el('thead');
|
||||
const hr = el('tr');
|
||||
for (const h of ['지층', '하단 EL (m)', '']) hr.appendChild(el('th', h));
|
||||
head.appendChild(hr);
|
||||
table.appendChild(head);
|
||||
const body = el('tbody');
|
||||
rows.forEach((row, i) => {
|
||||
const tr = el('tr');
|
||||
const select = el('select');
|
||||
for (const s of STRATA_NAMES) {
|
||||
const o = el('option', s);
|
||||
o.value = s;
|
||||
select.appendChild(o);
|
||||
}
|
||||
if (!(STRATA_NAMES as readonly string[]).includes(row.name)) {
|
||||
const o = el('option', row.name);
|
||||
o.value = row.name;
|
||||
select.appendChild(o);
|
||||
}
|
||||
select.value = row.name;
|
||||
select.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, name: select.value } : r))));
|
||||
const bottomInput = el('input');
|
||||
bottomInput.type = 'number';
|
||||
bottomInput.step = '0.001';
|
||||
bottomInput.value = String(row.bottomEl);
|
||||
bottomInput.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, bottomEl: Number(bottomInput.value) } : r))));
|
||||
const del = el('button', '삭제', 'btn small');
|
||||
del.type = 'button';
|
||||
del.addEventListener('click', () => setStrataRows(name, rows.filter((_, j) => j !== i)));
|
||||
tr.append(...[select, bottomInput, del].map((x) => {
|
||||
const td = el('td');
|
||||
td.appendChild(x);
|
||||
return td;
|
||||
}));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
table.appendChild(body);
|
||||
wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table);
|
||||
const add = el('button', '지층 행 추가', 'btn small');
|
||||
add.type = 'button';
|
||||
add.addEventListener('click', () => {
|
||||
const last = rows[rows.length - 1];
|
||||
const next = last ? (STRATA_NAMES as readonly string[])[Math.min((STRATA_NAMES as readonly string[]).indexOf(last.name) + 1, STRATA_NAMES.length - 1)] : STRATA_NAMES[0];
|
||||
setStrataRows(name, [...rows, { name: next, bottomEl: Number(((last?.bottomEl ?? d.groundEl) - 2).toFixed(3)) }]);
|
||||
});
|
||||
wrap.appendChild(add);
|
||||
|
||||
// 단면 (삽도): the footings and piles are the generated model; the layers and water level are the inputs.
|
||||
if (pier.model) {
|
||||
const fig = el('figure', undefined, 'ground-fig');
|
||||
fig.innerHTML = groundSectionSvg(d, pier.model);
|
||||
fig.appendChild(el('figcaption', '단면 · 기초와 말뚝은 생성본, 지층과 지하수위는 입력값', 'muted small'));
|
||||
wrap.appendChild(fig);
|
||||
}
|
||||
|
||||
// 일괄 적용: copies the 위치 and 지하수위 to the other checked piers (속성 복사 item 'ground-position').
|
||||
const others = state.piers.filter((q) => q.checked && q.input.name !== name);
|
||||
const batch = el('button', `${name} 외 일괄 대상 ${others.length}기에 적용`, 'btn small');
|
||||
batch.type = 'button';
|
||||
batch.title = '위치와 지하수위 설정을 체크한 다른 교각에 적용합니다.';
|
||||
batch.disabled = others.length === 0;
|
||||
batch.addEventListener('click', () => applyGroundPositionTo(pier, others));
|
||||
wrap.appendChild(batch);
|
||||
|
||||
// ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button.
|
||||
|
||||
wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl'));
|
||||
const importInput = el('input');
|
||||
importInput.type = 'file';
|
||||
@@ -1734,7 +1884,7 @@ function renderPlan(): void {
|
||||
changes.replaceChildren();
|
||||
const pending = pier ? pendingKeys(pier) : [];
|
||||
for (const key of pier ? overrideKeys(pier) : []) {
|
||||
const li = el('li', `${FIELD_LABELS[key]}: 기본 ${pier!.defaults[key] ?? '없음'} → 현재 ${pier!.draft[key] ?? '없음'}${pending.includes(key) ? ' (미반영)' : ''}`);
|
||||
const li = el('li', `${FIELD_LABELS[key]}: 기본 ${valueText(key, pier!.defaults[key])} → 현재 ${valueText(key, pier!.draft[key])}${pending.includes(key) ? ' (미반영)' : ''}`);
|
||||
const revert = el('button', '되돌리기', 'btn small');
|
||||
revert.type = 'button';
|
||||
revert.addEventListener('click', () => revertField(pier!.input.name, key));
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PierModel } from '../core/geometry';
|
||||
import type { PierInput } from '../core/types';
|
||||
import { SUPPORT_STRATUM } from '../core/ground';
|
||||
|
||||
/** Layer fills from the top down (시안 2026-10-26: 지층 1 light, deeper layers darker). */
|
||||
const LAYER_FILLS = ['#EADFC8', '#DCCBA8', '#C9B38D', '#B9A37E', '#A8926F', '#97815F'];
|
||||
|
||||
const WIDTH = 400;
|
||||
const PLOT_H = 300;
|
||||
const TOP_PAD = 24;
|
||||
|
||||
/**
|
||||
* 지반·지층 단면 (삽도). Drawn from the inputs and the generated footings and piles, on one vertical EL scale.
|
||||
* Horizontal positions are schematic (no dimensions): piles are spread across the footing.
|
||||
*/
|
||||
export function groundSectionSvg(input: PierInput, model: PierModel): string {
|
||||
const rows = input.strataRows ?? [];
|
||||
const ground = input.groundEl;
|
||||
const footTop = Math.max(...model.footings.map((f) => f.top));
|
||||
const footBottom = Math.min(...model.footings.map((f) => f.bottom));
|
||||
const pileTips = model.piles.map((p) => p.bottom);
|
||||
const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : ground - 1;
|
||||
const water = input.groundWaterOn && input.groundWaterEl !== undefined && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined;
|
||||
|
||||
const yMin = Math.min(footBottom, lowestRow, ...pileTips, water ?? ground) - 1;
|
||||
const yMax = Math.max(ground, footTop, input.copingTopEl) + 0.5;
|
||||
const scale = PLOT_H / (yMax - yMin);
|
||||
const y = (el: number) => TOP_PAD + (yMax - el) * scale;
|
||||
const H = TOP_PAD + PLOT_H + 16;
|
||||
|
||||
const parts: string[] = [];
|
||||
// Layers: each band runs from the upper boundary (ground for the first) down to its 하단 EL.
|
||||
let upper = ground;
|
||||
rows.forEach((row, i) => {
|
||||
const top = y(upper);
|
||||
const bottom = y(row.bottomEl);
|
||||
parts.push(`<rect x="0" y="${top}" width="${WIDTH}" height="${bottom - top}" fill="${LAYER_FILLS[i % LAYER_FILLS.length]}"></rect>`);
|
||||
parts.push(`<text x="8" y="${(top + bottom) / 2 + 4}" font-size="12" fill="#18212B">${escapeText(row.name)}</text>`);
|
||||
if (row.name === SUPPORT_STRATUM) {
|
||||
parts.push(`<line x1="0" y1="${top}" x2="${WIDTH}" y2="${top}" stroke="#5B4A2E" stroke-width="2"></line>`);
|
||||
parts.push(`<text x="8" y="${top - 4}" font-size="12" fill="#18212B">연암층 상면 EL ${upper.toFixed(3)}</text>`);
|
||||
}
|
||||
upper = row.bottomEl;
|
||||
});
|
||||
if (rows.length === 0 || upper > yMin) {
|
||||
parts.push(`<rect x="0" y="${y(upper)}" width="${WIDTH}" height="${y(yMin) - y(upper)}" fill="${LAYER_FILLS[rows.length % LAYER_FILLS.length]}"></rect>`);
|
||||
}
|
||||
|
||||
// Ground line and label.
|
||||
parts.push(`<line x1="0" y1="${y(ground)}" x2="${WIDTH}" y2="${y(ground)}" stroke="#8A6D3B" stroke-width="1.5"></line>`);
|
||||
parts.push(`<text x="8" y="${y(ground) - 6}" font-size="12" fill="#18212B">지반고 ${ground.toFixed(3)}</text>`);
|
||||
|
||||
// Groundwater (only when the input is on and has a value).
|
||||
if (water !== undefined) {
|
||||
parts.push(`<line x1="0" y1="${y(water)}" x2="${WIDTH}" y2="${y(water)}" stroke="#2F6DB5" stroke-dasharray="6 4"></line>`);
|
||||
parts.push(`<text x="8" y="${y(water) - 4}" font-size="12" fill="#123E70">지하수위 ${water.toFixed(3)}</text>`);
|
||||
}
|
||||
|
||||
// Footing, column stub, and piles.
|
||||
const cx = WIDTH / 2;
|
||||
const colW = 30;
|
||||
parts.push(`<rect x="${cx - colW / 2}" y="${y(yMax)}" width="${colW}" height="${y(footTop) - y(yMax)}" fill="#D8C7AA" stroke="#9C8763"></rect>`);
|
||||
const footW = 140;
|
||||
parts.push(`<rect x="${cx - footW / 2}" y="${y(footTop)}" width="${footW}" height="${y(footBottom) - y(footTop)}" fill="#B7C7D3" stroke="#6E8496"></rect>`);
|
||||
const sValues = [...new Set(model.piles.map((p) => p.s))].sort((a, b) => a - b);
|
||||
const spread = sValues.length > 1 ? (footW - 20) / (sValues.length - 1) : 0;
|
||||
sValues.forEach((s) => {
|
||||
const tips = model.piles.filter((p) => p.s === s);
|
||||
const x = sValues.length > 1 ? cx - footW / 2 + 10 + sValues.indexOf(s) * spread : cx;
|
||||
const tip = Math.min(...tips.map((p) => p.bottom));
|
||||
parts.push(`<rect x="${x - 3}" y="${y(footBottom)}" width="6" height="${y(tip) - y(footBottom)}" fill="#7D8A97"></rect>`);
|
||||
});
|
||||
|
||||
return `<svg viewBox="0 0 ${WIDTH} ${H}" width="100%" style="max-width: ${WIDTH}px; height: auto" role="img" aria-label="지반·지층 단면: 지반면, 지층, 지하수위, 기초와 말뚝">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
function escapeText(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildPier } from '../src/core/geometry';
|
||||
import { validateGround } from '../src/core/ground';
|
||||
import { changedKeys } from '../src/core/changes';
|
||||
import { COPY_ITEMS, applyCopy, planCopy } from '../src/core/copy';
|
||||
import type { Issue, PierInput } from '../src/core/types';
|
||||
import { groundSectionSvg } from '../src/view/ground-svg';
|
||||
import { loadP1 } from './helpers';
|
||||
|
||||
const base = loadP1();
|
||||
|
||||
function withGround(extra: Partial<PierInput>): PierInput {
|
||||
return { ...base, groundEl: 28.931, ...extra };
|
||||
}
|
||||
|
||||
function ground(input: PierInput): string[] {
|
||||
const issues: Issue[] = [];
|
||||
validateGround(input, issues);
|
||||
return issues.map((i) => i.code);
|
||||
}
|
||||
|
||||
const GOOD_ROWS = [
|
||||
{ name: '매립토', bottomEl: 27.5 },
|
||||
{ name: '풍화토', bottomEl: 22 },
|
||||
{ name: '연암', bottomEl: 10 },
|
||||
];
|
||||
|
||||
describe('지반·지층 입력 검사 (G01–G03)', () => {
|
||||
it('no issues for a well-ordered set of rows and a water level inside the range', () => {
|
||||
expect(ground(withGround({ strataRows: GOOD_ROWS, groundWaterOn: true, groundWaterEl: 25 }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('G01: a layer whose bottom EL is not below the boundary above it', () => {
|
||||
const rows = [{ name: '매립토', bottomEl: 27.5 }, { name: '풍화토', bottomEl: 28 }];
|
||||
expect(ground(withGround({ strataRows: rows }))).toEqual(['G01']);
|
||||
});
|
||||
|
||||
it('G01: the first layer must be below the ground EL', () => {
|
||||
expect(ground(withGround({ strataRows: [{ name: '매립토', bottomEl: 29 }] }))).toEqual(['G01']);
|
||||
});
|
||||
|
||||
it('G03: a layer name outside the strata list', () => {
|
||||
expect(ground(withGround({ strataRows: [{ name: '점토', bottomEl: 27 }] }))).toEqual(['G03']);
|
||||
});
|
||||
|
||||
it('G02: the water level above the ground, below the lowest layer, or missing while on', () => {
|
||||
expect(ground(withGround({ strataRows: GOOD_ROWS, groundWaterOn: true, groundWaterEl: 29 }))).toEqual(['G02']);
|
||||
expect(ground(withGround({ strataRows: GOOD_ROWS, groundWaterOn: true, groundWaterEl: 5 }))).toEqual(['G02']);
|
||||
expect(ground(withGround({ groundWaterOn: true }))).toEqual(['G02']);
|
||||
});
|
||||
|
||||
it('the water level is not checked when it is off', () => {
|
||||
expect(ground(withGround({ strataRows: GOOD_ROWS, groundWaterOn: false, groundWaterEl: 99 }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('일괄 적용 (속성 복사 · 위치·지하수위)', () => {
|
||||
it('copies only the position and the water level, not the layer rows', () => {
|
||||
const source = withGround({ groundPosition: '하상', groundWaterOn: true, groundWaterEl: 25, strataRows: GOOD_ROWS });
|
||||
const target = withGround({ groundPosition: '육상', strataRows: [] });
|
||||
const plan = planCopy(source, target, ['ground-position']);
|
||||
expect(plan.changes.map((c) => c.key).sort()).toEqual(['groundPosition', 'groundWaterEl', 'groundWaterOn']);
|
||||
const next = applyCopy(target, plan);
|
||||
expect(next.groundPosition).toBe('하상');
|
||||
expect(next.strataRows).toEqual([]);
|
||||
});
|
||||
|
||||
it('the item is off by default, so the existing 속성 복사 defaults do not change', () => {
|
||||
expect(COPY_ITEMS['ground-position'].defaultOn).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('변경 추적 (지반·지층 키)', () => {
|
||||
it('a changed layer row or water level is reported as a change', () => {
|
||||
const a = withGround({ strataRows: GOOD_ROWS });
|
||||
const b = withGround({ strataRows: [...GOOD_ROWS, { name: '경암', bottomEl: 5 }] });
|
||||
expect(changedKeys(a, b)).toEqual(['strataRows']);
|
||||
expect(changedKeys(a, { ...a, groundWaterEl: 20 })).toEqual(['groundWaterEl']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('단면 그림 (삽도)', () => {
|
||||
const model = buildPier(base);
|
||||
|
||||
it('shows the ground EL and one band per layer row, with the 연암 top line', () => {
|
||||
const svg = groundSectionSvg(withGround({ strataRows: GOOD_ROWS }), model);
|
||||
expect(svg).toContain('지반고 28.931');
|
||||
expect(svg).toContain('연암층 상면 EL 22.000');
|
||||
expect((svg.match(/fill="#(EADFC8|DCCBA8|C9B38D)"/g) ?? []).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('draws the water level only when it is on and has a value', () => {
|
||||
expect(groundSectionSvg(withGround({ strataRows: GOOD_ROWS, groundWaterOn: true, groundWaterEl: 25 }), model)).toContain('지하수위 25.000');
|
||||
// The dashed water line and its label are absent (the aria-label always names 지하수위).
|
||||
const off = groundSectionSvg(withGround({ strataRows: GOOD_ROWS, groundWaterOn: false, groundWaterEl: 25 }), model);
|
||||
expect(off).not.toContain('stroke-dasharray="6 4"');
|
||||
expect(off).not.toContain('지하수위 25');
|
||||
});
|
||||
|
||||
it('with no layer rows, the ground band still fills the drawing down to its bottom', () => {
|
||||
const svg = groundSectionSvg(withGround({}), model);
|
||||
expect(svg).toContain('<svg');
|
||||
expect(svg).toContain('지반고 28.931');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user