feat(abut): 교대 지반·지층 카드 — 교각과 같은 위치 · 수위 · 지층 행 입력, 교대 단면 삽도, 검사
- 위치 (육상 · 하상 · 해상)와 위치별 수위 입력, 지층 행 표 (교각과 공용 strataRowsEditor), 지반 모델 지층 불러오기 - 검사: 교각과 같은 G01–G14, 말뚝 근입 G04, 지반고 < 저판 하면이면 정보 AI01 - 삽도: 지층 · 수위 띠를 교각과 공용으로 빼고, 교대축 직각 단면(저판 · 벽체 · 흉벽 · 말뚝 열)을 EL. 표기로 그림 - 자동 값: 지반고 (엑셀 입력), 연암층 상면 EL, 지층 수. 프로젝트 파일 저장 · 검증 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9835fb42b3
commit
59c2607418
12 files changed
+527
-135
No files matched your search
@@ -199,6 +199,7 @@
|
||||
<div id="abut-panel-plan-bearings" class="edit-row"></div>
|
||||
<div id="abut-panel-plan-wing" class="edit-row"></div>
|
||||
<div id="abut-panel-plan-slab" class="edit-row"></div>
|
||||
<div id="abut-panel-plan-ground" class="edit-row"></div>
|
||||
<div id="abut-panel-plan-shape" class="edit-row abut-shape-view"></div>
|
||||
<div id="abut-panel-plan-footing" class="edit-row abut-shape-view"></div>
|
||||
<p id="abut-panel-plan-msg" class="muted" aria-live="polite"></p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { WingForm } from './geometry';
|
||||
import { WING_DIMS_DEFAULT, wingDimsIssues, type WingDims } from './wings';
|
||||
import { ABUT_SHAPE_DEFAULT, normalizeShape, shapeIssues, type AbutShape } from './shape';
|
||||
import { resolveSlab, slabIssues, type AbutSlab, type SlabForm } from './slab';
|
||||
import type { AbutGround } from './ground';
|
||||
import { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table';
|
||||
|
||||
export type AbutEditKey = 'seatEl' | 'foundationBottomEl' | 'groundEl' | 'pileLengthMm' | 'skewDeg' | 'slopeI';
|
||||
@@ -149,6 +150,20 @@ export const SLAB_EDIT_FIELDS: { key: 'lengthMm' | 'thicknessMm' | 'parapetGapMm
|
||||
{ key: 'haunchLengthMm', label: '헌치 길이 (mm)', exposedOnly: true, note: '가정 · 삽도 비율' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 지반·지층 입력 (2026-11-04): merges the given values into the record's 지반; undefined removes a value. Numbers must be finite.
|
||||
* The values are checked by the card (G01–G14), not refused here, as on the pier card.
|
||||
*/
|
||||
export function setAbutGround(rec: AbutRecord, patch: Partial<AbutGround>): AbutRecord {
|
||||
for (const [key, v] of Object.entries(patch)) {
|
||||
if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
|
||||
if (key === 'strataRows' && (v as { bottomEl: number }[]).some((r) => !Number.isFinite(r.bottomEl))) throw new Error('지층 하단 EL은 숫자여야 합니다.');
|
||||
}
|
||||
const ground: AbutGround = { ...(rec.ground ?? {}), ...patch };
|
||||
for (const key of Object.keys(ground) as (keyof AbutGround)[]) if (ground[key] === undefined) delete ground[key];
|
||||
return { ...rec, ground };
|
||||
}
|
||||
|
||||
/** Changes one 접속슬래브 dim, refused with a Korean message when the slab would not build. */
|
||||
export function applySlabEdit(rec: AbutRecord, key: (typeof SLAB_EDIT_FIELDS)[number]['key'], value: number): AbutRecord {
|
||||
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 교대 지반·지층 (2026-11-04, 시안 ② 지반·지층 카드 = 교각과 같은 카드). 순수 함수.
|
||||
*
|
||||
* - 입력은 교각과 같은 이름이다: 위치(육상 · 하상 · 해상), 지하수위, 설계홍수위 · 세굴, 고조위 · 저조위, 지층 행.
|
||||
* 지반고는 엑셀 입력(①에서 수정)이다. 표시만 하고 형상 계산에는 쓰지 않는다.
|
||||
* - 검사는 교각과 같다(G01–G14, 말뚝 근입 G04). 지반고가 저판 하면보다 낮으면 흙덮개가 없다는 정보 AI01을 더한다.
|
||||
*/
|
||||
import { SUPPORT_STRATUM, validateGround, validatePileEmbedment, validateStrataNValues, type GroundCheckInput } from '../ground';
|
||||
import type { Issue, PierInput } from '../types';
|
||||
import type { AbutMembers } from './members';
|
||||
import type { AbutRecord } from './parse';
|
||||
|
||||
export type AbutGround = Pick<PierInput, 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl' | 'strataRows'>;
|
||||
|
||||
/** 말뚝 두께 (mm): spec_abut 말뚝 Φ600 × 12T. Used only by the 해상 부식 여유 check. */
|
||||
const ABUT_PILE_THICKNESS_MM = 12;
|
||||
|
||||
/** The check input of an abutment: its 지반 values, 지반고 and 저판 하면 (기초 하단 when the 저판 is not built). */
|
||||
export function abutGroundCheckInput(rec: AbutRecord, members?: AbutMembers): GroundCheckInput {
|
||||
return {
|
||||
name: rec.name,
|
||||
groundEl: rec.groundEl,
|
||||
footingBottomEl: members?.footingBottomEl ?? rec.foundationBottomEl,
|
||||
pileThicknessMm: ABUT_PILE_THICKNESS_MM,
|
||||
...(rec.ground ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 연암 상면 EL: the bottom of the row above the 연암 row (지반고 for the first row); undefined without a 연암 row. */
|
||||
export function supportTopEl(groundEl: number, rows: { name: string; bottomEl: number }[] | undefined): number | undefined {
|
||||
const list = rows ?? [];
|
||||
const k = list.findIndex((r) => r.name === SUPPORT_STRATUM);
|
||||
if (k < 0) return undefined;
|
||||
return k === 0 ? groundEl : list[k - 1].bottomEl;
|
||||
}
|
||||
|
||||
/** Issues of the 지반·지층 card: the pier checks plus AI01 (지반고 below the 저판 하면). */
|
||||
export function abutGroundIssues(rec: AbutRecord, members?: AbutMembers): Issue[] {
|
||||
const input = abutGroundCheckInput(rec, members);
|
||||
const issues: Issue[] = [];
|
||||
validateGround(input, issues);
|
||||
validateStrataNValues(input, issues);
|
||||
if (members && members.pileBottomEl !== undefined && members.piles.length > 0) {
|
||||
validatePileEmbedment(input, { piles: [{ bottom: members.pileBottomEl, diameter: members.shape.pileDiameterM }] }, issues);
|
||||
}
|
||||
if (rec.groundEl < input.footingBottomEl) {
|
||||
issues.push({
|
||||
code: 'AI01',
|
||||
level: 'I',
|
||||
pier: rec.name,
|
||||
message: `지반고 ${rec.groundEl.toFixed(3)}이 저판 하면 ${input.footingBottomEl.toFixed(3)}보다 ${(input.footingBottomEl - rec.groundEl).toFixed(3)} 낮아 흙덮개가 없습니다.`,
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** True when any 지반 value differs from the default (육상, 지하수위 없음, 지층 행 없음). */
|
||||
export function abutGroundChanged(ground: AbutGround | undefined): boolean {
|
||||
if (!ground) return false;
|
||||
return Object.entries(ground).some(([key, v]) => {
|
||||
if (v === undefined) return false;
|
||||
if (key === 'groundPosition') return v !== '육상';
|
||||
if (key === 'groundWaterOn') return v === true;
|
||||
if (key === 'strataRows') return (v as unknown[]).length > 0;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WingForm } from './geometry';
|
||||
import { ABUT_SHAPE_DEFAULT, normalizeShape, wallThicknessMm, type AbutShape } from './shape';
|
||||
import { resolveSlab, SLAB_FORM_LABEL, slabChangedKeys, slabSlopeLabel, type AbutSlab } from './slab';
|
||||
import { abutGroundChanged, type AbutGround } from './ground';
|
||||
|
||||
/** 형상 입력 중 벽체·흉벽 카드와 확대기초·말뚝 카드의 항목 (변경 표시를 카드별로 나눈다). */
|
||||
const WALL_KEYS: (keyof AbutShape)[] = ['wallFrontMm', 'parapetHeightMm', 'parapetThicknessMm', 'bracketHeightMm', 'bracketWidthMm', 'shrinkJointCount', 'parapetLevelLeftMm', 'parapetLevelRightMm', 'seatStepIgnoreMm'];
|
||||
@@ -12,14 +13,14 @@ const FOOTING_KEYS: (keyof AbutShape)[] = [
|
||||
/** 부재 · 교대 list in ② (시안): the card each row opens. */
|
||||
export type AbutMemberId = 'bearing' | 'shape' | 'footing' | 'wing' | 'slab' | 'ground';
|
||||
|
||||
/** Cards that take input in this step; ground only shows its values (접속슬래브 입력 2026-11-04). */
|
||||
/** Cards that take input in this step: all six (접속슬래브 · 지반 입력 2026-11-04). */
|
||||
export const ABUT_MEMBER_EDITABLE: Record<AbutMemberId, boolean> = {
|
||||
bearing: true,
|
||||
shape: true,
|
||||
footing: true,
|
||||
wing: true,
|
||||
slab: true,
|
||||
ground: false,
|
||||
ground: true,
|
||||
};
|
||||
|
||||
const WING_LABEL: Record<WingForm, string> = { general: '일반형', short: '단축형', none: '없음' };
|
||||
@@ -30,6 +31,7 @@ export interface AbutMemberInput {
|
||||
shape?: Partial<AbutShape>;
|
||||
wings?: { left: WingForm; right: WingForm };
|
||||
slab?: Partial<AbutSlab>;
|
||||
ground?: AbutGround;
|
||||
groundEl: number;
|
||||
}
|
||||
|
||||
@@ -82,6 +84,11 @@ export function abutMemberRows(rec: AbutMemberInput, slabWidth: string): AbutMem
|
||||
summary: `${SLAB_FORM_LABEL[slab.form]} · ${slabSlopeLabel(slab)} · 폭 ${slabWidth}`,
|
||||
changed: slabChangedKeys(rec.slab).length > 0,
|
||||
},
|
||||
{ id: 'ground', label: '지반·지층', summary: `지반고 ${rec.groundEl.toFixed(3)} m`, changed: false },
|
||||
{
|
||||
id: 'ground',
|
||||
label: '지반·지층',
|
||||
summary: `${rec.ground?.groundPosition ?? '육상'} · 지층 ${rec.ground?.strataRows?.length ?? 0} · 지반고 EL.${rec.groundEl.toFixed(3)}`,
|
||||
changed: abutGroundChanged(rec.ground),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type { AbutShape } from './shape';
|
||||
import type { AbutBearingRule } from './bearing-table';
|
||||
import type { WingDims } from './wings';
|
||||
import type { AbutSlab } from './slab';
|
||||
import type { AbutGround } from './ground';
|
||||
|
||||
type Cell = string | number | null | undefined;
|
||||
type Grid = Cell[][];
|
||||
@@ -62,6 +63,8 @@ export interface AbutRecord {
|
||||
wingDims?: { left?: Partial<WingDims>; right?: Partial<WingDims> };
|
||||
/** 접속슬래브 입력 (② 접속슬래브 카드: 형식, 경사, 치수 mm). Only the changed values. Absent = 매립형 · 하향 4 % 기본값. */
|
||||
slab?: Partial<AbutSlab>;
|
||||
/** 지반·지층 입력 (② 지반·지층 카드, 교각과 같은 필드). Absent = 육상, 지하수위 없음, 지층 행 없음. */
|
||||
ground?: AbutGround;
|
||||
/** 예전 "좌우 같게" 값 (2026-11-04 이전 파일). 지금은 읽기만 하고 쓰지 않는다. */
|
||||
wingsLinked?: boolean;
|
||||
}
|
||||
|
||||
+14
-6
@@ -1,6 +1,14 @@
|
||||
import type { PierModel } from './geometry';
|
||||
import type { Issue, PierInput } from './types';
|
||||
|
||||
/**
|
||||
* The values the 지반·지층 checks read (2026-11-04: shared by the pier and the abutment card). A pier passes its input;
|
||||
* an abutment passes its record's 지반 values with its 지반고 and 저판 하면.
|
||||
*/
|
||||
export type GroundCheckInput = Pick<
|
||||
PierInput,
|
||||
'name' | 'groundEl' | 'footingBottomEl' | 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl' | 'strataRows' | 'pileThicknessMm'
|
||||
>;
|
||||
|
||||
/** 위치 choices of the 지반·지층 card (시안 2026-10-26). */
|
||||
export const GROUND_POSITIONS = ['육상', '하상', '해상'] as const;
|
||||
|
||||
@@ -28,7 +36,7 @@ export const CORROSION_ALLOWANCE_MM = 1;
|
||||
* G03: 층 이름은 지층 목록에 있어야 함. 하상: G07 DHWL 없음, G08 DHWL이 지반고보다 낮음, G09 세굴선 확인.
|
||||
* Ground values are inputs only; they do not change the 3D model.
|
||||
*/
|
||||
export function validateGround(input: PierInput, issues: Issue[]): void {
|
||||
export function validateGround(input: GroundCheckInput, issues: Issue[]): void {
|
||||
const rows = input.strataRows ?? [];
|
||||
let upper = input.groundEl;
|
||||
rows.forEach((row, i) => {
|
||||
@@ -70,7 +78,7 @@ export function validateGround(input: PierInput, issues: Issue[]): void {
|
||||
* G04: a tip above that limit. G05 (info): piles but no 연암 row, so the embedment is not checked.
|
||||
* The 연암 상면 is the bottom of the row above the 연암 row (ground EL for the first row).
|
||||
*/
|
||||
export function validatePileEmbedment(input: PierInput, model: PierModel, issues: Issue[]): void {
|
||||
export function validatePileEmbedment(input: GroundCheckInput, model: { piles: { bottom: number; diameter: number }[] }, issues: Issue[]): void {
|
||||
if (model.piles.length === 0) return;
|
||||
const rows = input.strataRows ?? [];
|
||||
const k = rows.findIndex((r) => r.name === SUPPORT_STRATUM);
|
||||
@@ -123,7 +131,7 @@ export function parseNValue(text: string | undefined): NValue {
|
||||
* G06 (경고): an N value that is not a blow count or a refusal in the accepted range. Empty is allowed.
|
||||
* The values are not used in the calculation yet; they will feed the 지지력 and 액상화 checks, so the format is strict.
|
||||
*/
|
||||
export function validateStrataNValues(input: PierInput, issues: Issue[]): void {
|
||||
export function validateStrataNValues(input: GroundCheckInput, issues: Issue[]): void {
|
||||
(input.strataRows ?? []).forEach((row, i) => {
|
||||
if (parseNValue(row.nValue).kind === 'invalid') {
|
||||
issues.push({
|
||||
@@ -137,7 +145,7 @@ export function validateStrataNValues(input: PierInput, issues: Issue[]): void {
|
||||
}
|
||||
|
||||
/** G09: the 세굴 깊이 is not negative, and the scour line stays above the footing bottom (하상·해상). */
|
||||
function validateScour(input: PierInput, issues: Issue[]): void {
|
||||
function validateScour(input: GroundCheckInput, issues: Issue[]): void {
|
||||
const scour = input.scourDepthM;
|
||||
if (scour === undefined) return;
|
||||
if (!(scour >= 0)) {
|
||||
@@ -152,7 +160,7 @@ function validateScour(input: PierInput, issues: Issue[]): void {
|
||||
* 저조위 is below 고조위. G13 missing; G10 해저면이 고조위보다 낮지 않음; G11 저조위가 고조위보다 낮지 않음;
|
||||
* G14 강관 두께가 부식 여유 기준 이하. 파랑과 선박 하중은 이번 범위에서 제외.
|
||||
*/
|
||||
function validateSea(input: PierInput, issues: Issue[]): void {
|
||||
function validateSea(input: GroundCheckInput, issues: Issue[]): void {
|
||||
const hw = input.highWaterEl;
|
||||
const lw = input.lowWaterEl;
|
||||
if (hw === undefined || lw === undefined || !Number.isFinite(hw) || !Number.isFinite(lw)) {
|
||||
|
||||
@@ -150,6 +150,18 @@ export function checkStoredAbutments(list: unknown): void {
|
||||
if (!ok) throw new Error(`${who}: 접속슬래브 값 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
}
|
||||
if (a.ground !== undefined) {
|
||||
const g = a.ground as Record<string, unknown>;
|
||||
for (const [key, v] of Object.entries(g)) {
|
||||
const ok =
|
||||
key === 'groundPosition' ? v === '육상' || v === '하상' || v === '해상'
|
||||
: key === 'groundWaterOn' ? typeof v === 'boolean'
|
||||
: key === 'strataRows'
|
||||
? Array.isArray(v) && v.every((r) => r && typeof r.name === 'string' && finite(r.bottomEl) && (r.nValue === undefined || typeof r.nValue === 'string'))
|
||||
: finite(v);
|
||||
if (!ok) throw new Error(`${who}: 지반 값 ${key}이(가) 맞지 않습니다.`);
|
||||
}
|
||||
}
|
||||
if (a.wingsLinked !== undefined && typeof a.wingsLinked !== 'boolean') throw new Error(`${who}: 좌우 같게 값이 맞지 않습니다.`);
|
||||
if (a.wings !== undefined) {
|
||||
const forms = ['general', 'short', 'none'];
|
||||
|
||||
+197
-62
@@ -25,7 +25,9 @@ import {
|
||||
applySlabEdit,
|
||||
setSlabForm,
|
||||
SLAB_EDIT_FIELDS,
|
||||
setAbutGround,
|
||||
} from './core/abut/edit';
|
||||
import { abutGroundIssues, supportTopEl, type AbutGround } from './core/abut/ground';
|
||||
import { ABUT_SLAB_DEFAULT, resolveSlab, SLAB_FORM_LABEL, slabGradePct, slabSection, slabSlopeLabel, type SlabForm } from './core/abut/slab';
|
||||
import { abutSlabPlanSvg, abutSlabSectionSvg } from './view/abut-slab-svg';
|
||||
import { ABUT_SHAPE_DEFAULT, footingEnds, normalizeShape, pileRowsMm, resolveShape, wallThicknessMm, type AbutShape } from './core/abut/shape';
|
||||
@@ -66,7 +68,7 @@ import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar
|
||||
import { DEFAULT_MATERIAL, FCK_MEMBERS, fckChanged, fyOf, GRADE_CHOICES, IMPORTED_FCK, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
|
||||
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity';
|
||||
import { groundElFor, parseGroundModel, strataRowsFor, type GroundMapLayer, type GroundModelFile } from './core/ground-model';
|
||||
import { CORROSION_ALLOWANCE_MM, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground';
|
||||
import { CORROSION_ALLOWANCE_MM, GROUND_POSITIONS, parseNValue, SEA_DATUM, STRATA_NAMES } from './core/ground';
|
||||
import { bearingEdgeDistances, integratedCheck, PAIR_ECCENTRICITY_LIMIT_MM, pairEccentricities } from './core/bearing-layout';
|
||||
import { bearingPlanSvg, PAIR_CHIP_LABELS } from './view/bearing-plan-svg';
|
||||
import { bearingSectionSvg } from './view/bearing-section-svg';
|
||||
@@ -86,7 +88,7 @@ import { footingCornerSvg } from './view/footing-corner-svg';
|
||||
import { footingLegRows } from './core/rebar/footing-legs';
|
||||
import { columnElevationSvg } from './view/column-elevation-svg';
|
||||
import { type ColumnSection, columnSectionSvg } from './view/column-section-svg';
|
||||
import { groundSectionSvg } from './view/ground-svg';
|
||||
import { abutGroundSectionSvg, groundSectionSvg } from './view/ground-svg';
|
||||
import { columnDiameterIssues } from './core/column-check';
|
||||
import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec';
|
||||
import { footingPlanSvg, footingSideSvg } from './view/footing-svg';
|
||||
@@ -742,7 +744,7 @@ const ABUT_QA_NOTES = [
|
||||
const ABUT_SLOTS = {
|
||||
shape: { root: 'abut-panel', name: 'abut-panel-name', input: 'abut-panel-input', toggle: 'abut-panel-edit-toggle', editor: 'abut-panel-editor', summary: 'abut-panel-summary', basic: 'abut-panel-basic', bearings: 'abut-panel-bearings', shape: 'abut-panel-shape', msg: 'abut-panel-msg', prefix: 'abs' },
|
||||
// ② has no 편집 toggle and no basic items: the card sits under the 3D view and shows the picked member (2026-11-03).
|
||||
plan: { wing: 'abut-panel-plan-wing', slab: 'abut-panel-plan-slab', footing: 'abut-panel-plan-footing', root: 'abut-panel-plan', name: 'abut-panel-plan-name', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' },
|
||||
plan: { wing: 'abut-panel-plan-wing', slab: 'abut-panel-plan-slab', ground: 'abut-panel-plan-ground', footing: 'abut-panel-plan-footing', root: 'abut-panel-plan', name: 'abut-panel-plan-name', bearings: 'abut-panel-plan-bearings', shape: 'abut-panel-plan-shape', msg: 'abut-panel-plan-msg', prefix: 'abp' },
|
||||
} as const;
|
||||
|
||||
/** Shows the 교대 입력 in place of the pier info when an abutment is selected (2026-11-03). */
|
||||
@@ -1340,6 +1342,122 @@ function fillAbutSlabCard(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ② 지반·지층 카드 (교대, 2026-11-04: 교각 카드와 같은 입력): 위치 → 위치별 수위 입력 → 단면 삽도 → 자동 값 → 지층 행 →
|
||||
* 지반 모델 → 검사 → 메모. 표시만 하고 형상 계산에는 쓰지 않는다. 지반고는 ①에서 고친다.
|
||||
*/
|
||||
function fillAbutGroundCard(
|
||||
box: HTMLDivElement,
|
||||
rec: AbutRecord,
|
||||
apply: (next: AbutRecord) => void,
|
||||
fail: (err: unknown, input: HTMLInputElement, back: string) => void,
|
||||
idOf: (suffix: string) => string,
|
||||
numberInput: (id: string, value: string) => HTMLInputElement,
|
||||
): void {
|
||||
const g: AbutGround = rec.ground ?? {};
|
||||
const position = g.groundPosition ?? '육상';
|
||||
const set = (patch: Partial<AbutGround>) => apply(setAbutGround(rec, patch));
|
||||
box.append(
|
||||
optionTiles(
|
||||
'위치',
|
||||
GROUND_POSITIONS.map((p) => ({ label: p, on: position === p, isDefault: p === '육상', pick: () => position !== p && set({ groundPosition: p }) })),
|
||||
'표시만 합니다 (형상 계산에 쓰지 않음)',
|
||||
),
|
||||
);
|
||||
// 위치별 수위 입력 (교각과 같음): 육상 지하수위, 하상 설계홍수위 · 세굴, 해상 고조위 · 저조위 · 세굴. 비우면 값 없음.
|
||||
const level = (key: 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl', label: string, note: string, disabled = false) => {
|
||||
const id = idOf(`ground-${key}`);
|
||||
const lab = el('label', label);
|
||||
lab.htmlFor = id;
|
||||
const v = g[key];
|
||||
const input = numberInput(id, v === undefined ? '' : String(v));
|
||||
input.disabled = disabled;
|
||||
input.addEventListener('change', () => {
|
||||
try {
|
||||
set({ [key]: input.value === '' ? undefined : Number(input.value) });
|
||||
} catch (err) {
|
||||
fail(err, input, v === undefined ? '' : String(v));
|
||||
}
|
||||
});
|
||||
box.append(lab, input, el('span', note, 'muted small'));
|
||||
};
|
||||
if (position === '하상') {
|
||||
level('floodLevelEl', '설계홍수위 DHWL EL (m)', '하상');
|
||||
level('scourDepthM', '세굴 깊이 (m)', '지반고 아래');
|
||||
} else if (position === '해상') {
|
||||
level('highWaterEl', '고조위 HWL (m)', `기준면 ${SEA_DATUM}`);
|
||||
level('lowWaterEl', '저조위 LWL (m)', `기준면 ${SEA_DATUM}`);
|
||||
level('scourDepthM', '세굴 깊이 (m)', `지반고 칸 = 해저면 EL · 부식 여유 ${CORROSION_ALLOWANCE_MM} mm (확인 필요)`);
|
||||
} else {
|
||||
const id = idOf('ground-water-on');
|
||||
const check = el('input');
|
||||
check.type = 'checkbox';
|
||||
check.id = id;
|
||||
check.checked = g.groundWaterOn === true;
|
||||
check.addEventListener('change', () => set({ groundWaterOn: check.checked }));
|
||||
const lab = el('label', '지하수위 고려');
|
||||
lab.htmlFor = id;
|
||||
box.append(lab, check, el('span', '', 'muted small'));
|
||||
level('groundWaterEl', '지하수위 EL (m)', g.groundWaterOn ? '' : '지하수위 고려를 켜면 입력', g.groundWaterOn !== true);
|
||||
}
|
||||
let members: AbutMembers | undefined;
|
||||
try {
|
||||
const model = buildAbut(toAbutInput(rec));
|
||||
members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape);
|
||||
} catch {
|
||||
members = undefined;
|
||||
}
|
||||
if (members && members.footingBottomEl !== undefined) {
|
||||
const s = members.shape;
|
||||
const fig = el('figure', undefined, 'ground-fig');
|
||||
fig.innerHTML = abutGroundSectionSvg({
|
||||
ground: { ...g, groundEl: rec.groundEl },
|
||||
footingTopEl: members.footingTopEl,
|
||||
footingBottomEl: members.footingBottomEl,
|
||||
seatEl: rec.seatEl,
|
||||
parapetTopEl: members.parapetTopStartEl,
|
||||
bracketHeightM: s.bracketHeightM,
|
||||
wallFrontM: s.wallFrontM,
|
||||
wallBackM: s.wallBackM,
|
||||
parapetBackM: s.parapetBackM,
|
||||
footingFrontM: s.footingFrontM,
|
||||
footingBackM: s.footingBackM,
|
||||
pileRowsT: members.piles.map((r) => r.t),
|
||||
pileBottomEl: members.pileBottomEl,
|
||||
});
|
||||
fig.append(el('figcaption', '단면 · 교대축 직각 · 저판 · 벽체 · 흉벽 · 말뚝은 생성본, 지층과 수위는 입력값 · 세로는 EL 축척, 가로는 모식', 'muted small'));
|
||||
box.append(fig);
|
||||
}
|
||||
const rows = g.strataRows ?? [];
|
||||
const top = supportTopEl(rec.groundEl, rows);
|
||||
const auto = (name: string, value: string, note: string) => box.append(el('label', name), el('span', value, 'num'), el('span', note, 'muted small'));
|
||||
auto('지반고 EL (엑셀 입력)', `EL.${rec.groundEl.toFixed(3)}`, '① 기본 정보에서 수정');
|
||||
auto('연암층 상면 EL (자동)', top === undefined ? '연암 행 없음' : `EL.${top.toFixed(3)}`, '지층 행에서');
|
||||
auto('지층 수 (자동)', `${rows.length}`, '지층 행에서');
|
||||
box.append(...strataRowsEditor(rows, rec.groundEl, (next) => set({ strataRows: next.length ? next : undefined })));
|
||||
// 지반 모델의 지층 (ground_model.json에 이 교대가 있을 때): one click fills the rows.
|
||||
if (groundModel) {
|
||||
const modelRows = strataRowsFor(groundModel, rec.name);
|
||||
if (modelRows) {
|
||||
const fromModel = el('button', `지반 모델의 지층 ${modelRows.length}개 불러오기`, 'btn small');
|
||||
fromModel.type = 'button';
|
||||
fromModel.addEventListener('click', () => set({ strataRows: modelRows.map((r) => ({ ...r })) }));
|
||||
box.append(fromModel);
|
||||
} else {
|
||||
box.append(el('p', `지반 모델에 ${rec.name}의 지층이 없습니다.`, 'muted small'));
|
||||
}
|
||||
}
|
||||
const issues = abutGroundIssues(rec, members);
|
||||
box.append(el('p', `검사 · ${issues.length ? issues.map((i) => `${i.code} ${i.message}`).join(' · ') : '경고 없음'}`, 'muted small abut-checks'));
|
||||
box.append(
|
||||
el(
|
||||
'p',
|
||||
'시안 메모 · 교각과 같은 카드입니다. 위치 · 수위 · 지층은 표시와 검사에만 쓰고 형상 계산에는 쓰지 않습니다(3D는 지반면만). 연암 행이 있으면 말뚝 선단 근입(연암 상면 − 1.0 D)을 검사합니다(G04). 지반고가 저판 하면보다 낮으면 흙덮개가 없다는 정보(AI01)를 표시합니다. 다른 구조물에 적용하기는 교대 속성 복사 범위 밖이라 두지 않았습니다.',
|
||||
'muted small abut-memo',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 확대기초 · 말뚝 카드의 자동 값, 열 위치, 열별 본수 표(− / +), 메모 (시안, 2026-11-04). 말뚝 길이는 ①에서 고친다. */
|
||||
function fillAbutFootingExtras(
|
||||
box: HTMLDivElement,
|
||||
@@ -1436,6 +1554,7 @@ function applyAbutMemberCard(rec: AbutRecord): void {
|
||||
$<HTMLElement>('abut-panel-plan-footing').hidden = member !== 'footing';
|
||||
$<HTMLElement>('abut-panel-plan-wing').hidden = member !== 'wing';
|
||||
$<HTMLElement>('abut-panel-plan-slab').hidden = member !== 'slab';
|
||||
$<HTMLElement>('abut-panel-plan-ground').hidden = member !== 'ground';
|
||||
// The card title is the picked row; its 요약 follows the name (시안 머리줄).
|
||||
const slabWidth = abutSummaryRows(rec).find(([label]) => label === '접속슬래브 폭')?.[1] ?? '—';
|
||||
const row = abutMemberRows(rec, slabWidth).find((r) => r.id === member);
|
||||
@@ -1521,6 +1640,11 @@ function fillAbutSlot(slot: (typeof ABUT_SLOTS)[keyof typeof ABUT_SLOTS], rec: A
|
||||
slabBox.replaceChildren();
|
||||
fillAbutSlabCard(slabBox, rec, apply, fail, idOf, numberInput);
|
||||
}
|
||||
if ('ground' in slot) {
|
||||
const groundBox = $<HTMLDivElement>(slot.ground);
|
||||
groundBox.replaceChildren();
|
||||
fillAbutGroundCard(groundBox, rec, apply, fail, idOf, numberInput);
|
||||
}
|
||||
for (const side of planCard ? [] : (['left', 'right'] as const)) {
|
||||
const id = idOf(`wing-${side}`);
|
||||
const lab = el('label', side === 'left' ? '좌 날개벽 (① 쪽)' : '우 날개벽 (n 쪽)');
|
||||
@@ -3430,6 +3554,75 @@ function setStrataRows(name: string, rows: StrataRow[]): void {
|
||||
renderAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 지층 행 표 (승인 2026-10-26; 2026-11-04 교각 · 교대 공용): one row per layer from the top down (지층 · 하단 EL · N값 · 삭제) and
|
||||
* the 행 추가 button. `set` receives the new rows.
|
||||
*/
|
||||
function strataRowsEditor(rows: StrataRow[], groundEl: number, set: (rows: StrataRow[]) => void): HTMLElement[] {
|
||||
const out: HTMLElement[] = [];
|
||||
out.push(el('p', `지층 행 (위에서 아래 · 하단 EL m · 지반고 ${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)', 'N값 (타수 · 타수/관입량 mm)', '']) 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', () => set(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', () => set(rows.map((r, j) => (j === i ? { ...r, bottomEl: Number(bottomInput.value) } : r))));
|
||||
// N값 as text: a blow count or "타수/관입량 mm" (승인 2026-10-26). Empty means not known yet.
|
||||
const nInput = el('input');
|
||||
nInput.type = 'text';
|
||||
nInput.placeholder = '예: 23 · 50/5';
|
||||
nInput.value = row.nValue ?? '';
|
||||
nInput.addEventListener('change', () => {
|
||||
const v = nInput.value.trim();
|
||||
set(rows.map((r, j) => (j === i ? { ...r, nValue: v === '' ? undefined : v } : r)));
|
||||
});
|
||||
if (parseNValue(row.nValue).kind === 'invalid') nInput.classList.add('warn-text');
|
||||
const del = el('button', '삭제', 'btn small');
|
||||
del.type = 'button';
|
||||
del.addEventListener('click', () => set(rows.filter((_, j) => j !== i)));
|
||||
tr.append(...[select, bottomInput, nInput, del].map((x) => {
|
||||
const td = el('td');
|
||||
td.appendChild(x);
|
||||
return td;
|
||||
}));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
table.appendChild(body);
|
||||
const scroll = el('div', undefined, 'table-scroll');
|
||||
scroll.appendChild(table);
|
||||
out.push(scroll);
|
||||
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];
|
||||
set([...rows, { name: next, bottomEl: Number(((last?.bottomEl ?? groundEl) - 2).toFixed(3)) }]);
|
||||
});
|
||||
out.push(add);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력).
|
||||
* The ground model import and the 지반고 apply stay at the bottom, as before.
|
||||
@@ -3506,65 +3699,7 @@ function renderGroundModel(box: HTMLDivElement, pier: Pier): void {
|
||||
}
|
||||
|
||||
// 지층 행 (승인 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)', 'N값 (타수 · 타수/관입량 mm)', '']) 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))));
|
||||
// N값 as text: a blow count or "타수/관입량 mm" (승인 2026-10-26). Empty means not known yet.
|
||||
const nInput = el('input');
|
||||
nInput.type = 'text';
|
||||
nInput.placeholder = '예: 23 · 50/5';
|
||||
nInput.value = row.nValue ?? '';
|
||||
nInput.addEventListener('change', () => {
|
||||
const v = nInput.value.trim();
|
||||
setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, nValue: v === '' ? undefined : v } : r)));
|
||||
});
|
||||
if (parseNValue(row.nValue).kind === 'invalid') nInput.classList.add('warn-text');
|
||||
const del = el('button', '삭제', 'btn small');
|
||||
del.type = 'button';
|
||||
del.addEventListener('click', () => setStrataRows(name, rows.filter((_, j) => j !== i)));
|
||||
tr.append(...[select, bottomInput, nInput, 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);
|
||||
wrap.append(...strataRowsEditor(d.strataRows ?? [], d.groundEl, (rows) => setStrataRows(name, rows)));
|
||||
// 지반 모델의 지층 기둥 (Python, 시추 자료가 있을 때): one click fills the rows (승인 2026-10-26).
|
||||
if (groundModel) {
|
||||
const modelRows = strataRowsFor(groundModel, name);
|
||||
|
||||
@@ -949,3 +949,5 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
||||
#abut-panel-plan .abut-wing-forms .abut-grp .abut-grp-title { cursor: pointer; font-weight: 600; color: #18212b; }
|
||||
#abut-panel-plan .abut-grp.compact .abut-opt { width: 64px; min-height: 48px; padding: 4px 2px; }
|
||||
#abut-panel-plan-slab > p, #abut-panel-plan-slab > .abut-wall-figs { grid-column: 1 / -1; }
|
||||
#abut-panel-plan-ground > p, #abut-panel-plan-ground > .table-scroll, #abut-panel-plan-ground > figure { grid-column: 1 / -1; }
|
||||
#abut-panel-plan-ground > figure { margin: 4px 0; max-width: 560px; }
|
||||
+132
-61
@@ -14,73 +14,15 @@ const TOP_PAD = 24;
|
||||
* 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;
|
||||
// 하상: the 설계홍수위 (solid) and the 세굴선 replace the groundwater (dashed, 승인 2026-10-26).
|
||||
const position = input.groundPosition ?? '육상';
|
||||
const river = position === '하상';
|
||||
const sea = position === '해상';
|
||||
const level = river
|
||||
? (Number.isFinite(input.floodLevelEl) ? input.floodLevelEl : undefined)
|
||||
: sea
|
||||
? (Number.isFinite(input.highWaterEl) ? input.highWaterEl : undefined)
|
||||
: (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined);
|
||||
// 해상: the 저조위 is drawn too (dashed), on the MSL datum.
|
||||
const lowLevel = sea && Number.isFinite(input.lowWaterEl) ? input.lowWaterEl : undefined;
|
||||
const groundLabel = sea ? '해저면 EL' : '지반고';
|
||||
const scour = river && Number.isFinite(input.scourDepthM) && (input.scourDepthM as number) >= 0 ? (input.scourDepthM as number) : undefined;
|
||||
const scourEl = scour !== undefined ? ground - scour : undefined;
|
||||
|
||||
const yMin = Math.min(footBottom, lowestRow, ...pileTips, level ?? ground, lowLevel ?? ground, scourEl ?? ground) - 1;
|
||||
const yMax = Math.max(ground, footTop, input.copingTopEl) + 0.5;
|
||||
const { yMin } = groundRange(input, [footBottom, ...pileTips]);
|
||||
const yMax = Math.max(input.groundEl, 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>`);
|
||||
// Caption just under the line, so it does not collide with the label of the band above.
|
||||
parts.push(`<text x="8" y="${top + 14}" 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">${groundLabel} ${ground.toFixed(3)}</text>`);
|
||||
|
||||
// Groundwater (육상, when on) as a dashed line; the 설계홍수위 (하상) as a solid line.
|
||||
if (level !== undefined) {
|
||||
const dash = river ? '' : ' stroke-dasharray="6 4"';
|
||||
const label = river ? '설계홍수위 DHWL' : sea ? '고조위 HWL' : '지하수위';
|
||||
parts.push(`<line x1="0" y1="${y(level)}" x2="${WIDTH}" y2="${y(level)}" stroke="#2F6DB5" stroke-width="${river ? 2 : 1}"${dash}></line>`);
|
||||
parts.push(`<text x="8" y="${y(level) - 4}" font-size="12" fill="#123E70">${label} ${level.toFixed(3)}</text>`);
|
||||
}
|
||||
// 해상: 저조위 (dashed, MSL datum).
|
||||
if (lowLevel !== undefined) {
|
||||
parts.push(`<line x1="0" y1="${y(lowLevel)}" x2="${WIDTH}" y2="${y(lowLevel)}" stroke="#2F6DB5" stroke-dasharray="6 4"></line>`);
|
||||
parts.push(`<text x="8" y="${y(lowLevel) + 12}" font-size="12" fill="#123E70">저조위 LWL ${lowLevel.toFixed(3)}</text>`);
|
||||
}
|
||||
// 세굴선 (하상·해상): the bed after scour.
|
||||
if (scourEl !== undefined) {
|
||||
parts.push(`<line x1="0" y1="${y(scourEl)}" x2="${WIDTH}" y2="${y(scourEl)}" stroke="#8A6D3B" stroke-dasharray="3 3"></line>`);
|
||||
parts.push(`<text x="8" y="${y(scourEl) + 12}" font-size="12" fill="#5B4A2E">세굴선 ${scourEl.toFixed(3)}</text>`);
|
||||
}
|
||||
const parts = groundBands(input, y, yMin, WIDTH, (v) => v.toFixed(3));
|
||||
|
||||
// Footing, column stub, and piles.
|
||||
const cx = WIDTH / 2;
|
||||
@@ -100,6 +42,135 @@ export function groundSectionSvg(input: PierInput, model: PierModel): string {
|
||||
return `<svg viewBox="0 0 ${WIDTH} ${H}" width="100%" style="max-width: ${WIDTH}px; height: auto" role="img" aria-label="지반·지층 단면: 지반면, 지층, 지하수위, 기초와 말뚝">${parts.join('')}</svg>`;
|
||||
}
|
||||
|
||||
/** The 지반·지층 values the bands read (a pier input, or an abutment's 지반 values with its 지반고). */
|
||||
type GroundBandInput = Pick<PierInput, 'groundEl' | 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl' | 'strataRows'>;
|
||||
|
||||
/** The water line (지하수위 / 설계홍수위 / 고조위), the 저조위 and the 세굴선 of the input, as ELs. */
|
||||
function groundLevels(input: GroundBandInput) {
|
||||
const position = input.groundPosition ?? '육상';
|
||||
const river = position === '하상';
|
||||
const sea = position === '해상';
|
||||
const level = river
|
||||
? (Number.isFinite(input.floodLevelEl) ? input.floodLevelEl : undefined)
|
||||
: sea
|
||||
? (Number.isFinite(input.highWaterEl) ? input.highWaterEl : undefined)
|
||||
: (input.groundWaterOn && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined);
|
||||
const lowLevel = sea && Number.isFinite(input.lowWaterEl) ? input.lowWaterEl : undefined;
|
||||
const scour = river && Number.isFinite(input.scourDepthM) && (input.scourDepthM as number) >= 0 ? (input.scourDepthM as number) : undefined;
|
||||
const scourEl = scour !== undefined ? input.groundEl - scour : undefined;
|
||||
return { river, sea, level, lowLevel, scourEl };
|
||||
}
|
||||
|
||||
/** Lowest EL to draw: 1 m below the deepest of the layers, the structure ELs given, and the water and scour lines. */
|
||||
function groundRange(input: GroundBandInput, structureEls: number[]): { yMin: number } {
|
||||
const rows = input.strataRows ?? [];
|
||||
const g = input.groundEl;
|
||||
const { level, lowLevel, scourEl } = groundLevels(input);
|
||||
const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : g - 1;
|
||||
return { yMin: Math.min(lowestRow, ...structureEls, level ?? g, lowLevel ?? g, scourEl ?? g) - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 지층 띠, 지반선, 수위선 (지하수위 점선 · 설계홍수위 실선 · 고조위 / 저조위), 세굴선 (2026-11-04: shared by the pier and the
|
||||
* abutment 삽도). `fmt` writes an EL (the pier writes "28.931", the abutment "EL.28.931").
|
||||
*/
|
||||
function groundBands(input: GroundBandInput, y: (el: number) => number, yMin: number, width: number, fmt: (el: number) => string): string[] {
|
||||
const rows = input.strataRows ?? [];
|
||||
const ground = input.groundEl;
|
||||
const { river, sea, level, lowLevel, scourEl } = groundLevels(input);
|
||||
const groundLabel = sea ? '해저면 EL' : '지반고';
|
||||
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>`);
|
||||
// Caption just under the line, so it does not collide with the label of the band above.
|
||||
parts.push(`<text x="8" y="${top + 14}" font-size="12" fill="#18212B">연암층 상면 ${fmt(upper).startsWith('EL') ? '' : 'EL '}${fmt(upper)}</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">${groundLabel} ${fmt(ground)}</text>`);
|
||||
// Groundwater (육상, when on) as a dashed line; the 설계홍수위 (하상) as a solid line.
|
||||
if (level !== undefined) {
|
||||
const dash = river ? '' : ' stroke-dasharray="6 4"';
|
||||
const label = river ? '설계홍수위 DHWL' : sea ? '고조위 HWL' : '지하수위';
|
||||
parts.push(`<line x1="0" y1="${y(level)}" x2="${width}" y2="${y(level)}" stroke="#2F6DB5" stroke-width="${river ? 2 : 1}"${dash}></line>`);
|
||||
parts.push(`<text x="8" y="${y(level) - 4}" font-size="12" fill="#123E70">${label} ${fmt(level)}</text>`);
|
||||
}
|
||||
// 해상: 저조위 (dashed, MSL datum).
|
||||
if (lowLevel !== undefined) {
|
||||
parts.push(`<line x1="0" y1="${y(lowLevel)}" x2="${width}" y2="${y(lowLevel)}" stroke="#2F6DB5" stroke-dasharray="6 4"></line>`);
|
||||
parts.push(`<text x="8" y="${y(lowLevel) + 12}" font-size="12" fill="#123E70">저조위 LWL ${fmt(lowLevel)}</text>`);
|
||||
}
|
||||
// 세굴선 (하상·해상): the bed after scour.
|
||||
if (scourEl !== undefined) {
|
||||
parts.push(`<line x1="0" y1="${y(scourEl)}" x2="${width}" y2="${y(scourEl)}" stroke="#8A6D3B" stroke-dasharray="3 3"></line>`);
|
||||
parts.push(`<text x="8" y="${y(scourEl) + 12}" font-size="12" fill="#5B4A2E">세굴선 ${fmt(scourEl)}</text>`);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** 교대 지반 삽도 입력 (2026-11-04): the 지반 values and the generated 저판 · 벽체 · 흉벽 · 말뚝 (t in m, 전면 +). */
|
||||
export interface AbutGroundDrawing {
|
||||
ground: GroundBandInput;
|
||||
footingTopEl: number;
|
||||
footingBottomEl: number;
|
||||
seatEl: number;
|
||||
parapetTopEl: number;
|
||||
bracketHeightM: number;
|
||||
wallFrontM: number;
|
||||
wallBackM: number;
|
||||
parapetBackM: number;
|
||||
footingFrontM: number;
|
||||
footingBackM: number;
|
||||
pileRowsT: number[];
|
||||
pileBottomEl?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 교대 지반·지층 단면 (삽도, 2026-11-04): 교대축 직각 단면(저판, 벽체, 브라켓 단차, 흉벽, 말뚝 열)을 지층 · 수위 위에 그린다.
|
||||
* 세로는 EL 한 축척, 가로는 단면 모양을 보이는 모식 축척이다. 왼쪽이 배면.
|
||||
*/
|
||||
export function abutGroundSectionSvg(d: AbutGroundDrawing): string {
|
||||
const g = d.ground;
|
||||
const { yMin } = groundRange(g, [d.footingBottomEl, ...(d.pileBottomEl === undefined ? [] : [d.pileBottomEl])]);
|
||||
const yMax = Math.max(g.groundEl, d.parapetTopEl) + 0.8;
|
||||
const scale = PLOT_H / (yMax - yMin);
|
||||
const y = (el: number) => TOP_PAD + (yMax - el) * scale;
|
||||
const H = TOP_PAD + PLOT_H + 16;
|
||||
const fmt = (v: number) => `EL.${v.toFixed(3)}`;
|
||||
const parts = groundBands(g, y, yMin, WIDTH, fmt);
|
||||
const kx = 150 / (d.footingFrontM - d.footingBackM);
|
||||
const cx = WIDTH / 2;
|
||||
const X = (t: number) => cx + t * kx;
|
||||
const rect = (t0: number, t1: number, e0: number, e1: number, style: string) =>
|
||||
`<rect x="${X(t0)}" y="${y(e1)}" width="${X(t1) - X(t0)}" height="${y(e0) - y(e1)}" ${style}></rect>`;
|
||||
const bracketTop = d.parapetTopEl - d.bracketHeightM;
|
||||
if (d.pileBottomEl !== undefined) {
|
||||
for (const t of d.pileRowsT) parts.push(rect(t - 0.15, t + 0.15, d.pileBottomEl, d.footingBottomEl, 'fill="#7D8A97"'));
|
||||
}
|
||||
parts.push(rect(d.footingBackM, d.footingFrontM, d.footingBottomEl, d.footingTopEl, 'fill="#B7C7D3" stroke="#6E8496"'));
|
||||
parts.push(rect(d.wallBackM, d.wallFrontM, d.footingTopEl, d.seatEl, 'fill="#D8C7AA" stroke="#9C8763"'));
|
||||
parts.push(rect(d.wallBackM, d.parapetBackM, d.seatEl, bracketTop, 'fill="#D8C7AA" stroke="#9C8763"'));
|
||||
parts.push(rect(d.parapetBackM, 0, d.seatEl, d.parapetTopEl, 'fill="#D8C7AA" stroke="#9C8763"'));
|
||||
// Right-aligned at the drawing edge so the longer EL labels are not cut off.
|
||||
parts.push(`<text x="${WIDTH - 4}" y="${y(d.footingBottomEl) + 14}" font-size="12" fill="#18212B" text-anchor="end">기초 하단 ${fmt(d.footingBottomEl)}</text>`);
|
||||
if (d.pileBottomEl !== undefined) parts.push(`<text x="${WIDTH - 4}" y="${y(d.pileBottomEl) + 14}" font-size="12" fill="#18212B" text-anchor="end">말뚝 선단 ${fmt(d.pileBottomEl)}</text>`);
|
||||
parts.push(`<text x="${X(0) + 4}" y="${y(d.parapetTopEl) + 4}" font-size="12" fill="#18212B">흉벽 상단 ${fmt(d.parapetTopEl)}</text>`);
|
||||
parts.push(`<text x="${WIDTH - 4}" y="${H - 4}" font-size="11" fill="#55606E" text-anchor="end">← 배면 · 전면 →</text>`);
|
||||
return `<svg viewBox="0 0 ${WIDTH} ${H}" width="100%" style="max-width: ${WIDTH + 80}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,71 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAbut } from '../src/core/abut/geometry';
|
||||
import { parseAbutments, toAbutInput, type AbutRecord } from '../src/core/abut/parse';
|
||||
import { buildMembers } from '../src/core/abut/members';
|
||||
import { setAbutGround } from '../src/core/abut/edit';
|
||||
import { abutGroundChanged, abutGroundIssues, supportTopEl } from '../src/core/abut/ground';
|
||||
import { abutMemberRows } from '../src/core/abut/member-list';
|
||||
import { checkStoredAbutments } from '../src/core/project';
|
||||
import { abutGroundSectionSvg } from '../src/view/ground-svg';
|
||||
|
||||
const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url));
|
||||
const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0];
|
||||
const membersOf = (r: AbutRecord) => buildMembers(buildAbut(toAbutInput(r)), { bottomEl: r.foundationBottomEl, pileLengthMm: r.pileLengthMm }, r.shape);
|
||||
const codes = (r: AbutRecord) => abutGroundIssues(r, membersOf(r)).map((i) => i.code);
|
||||
// A1: 지반고 33.984, 기초 하단 40.895, 말뚝 선단 19.295 (spec_abut §12).
|
||||
const rows = [
|
||||
{ name: '퇴적토', bottomEl: 30 },
|
||||
{ name: '풍화암', bottomEl: 22 },
|
||||
{ name: '연암', bottomEl: 10 },
|
||||
];
|
||||
|
||||
describe('교대 지반·지층 (2026-11-04, 교각 카드와 같은 입력)', () => {
|
||||
it('defaults: 육상, no water, no rows; only AI01 (지반고 below the 저판 하면) is reported', () => {
|
||||
expect(codes(rec)).toEqual(['AI01']);
|
||||
expect(abutGroundIssues(rec, membersOf(rec))[0].message).toContain('6.911');
|
||||
expect(abutGroundChanged(rec.ground)).toBe(false);
|
||||
});
|
||||
|
||||
it('the pier checks apply: G01 order, G02 water, G07 DHWL, G13 sea, G04 pile embedment into the 연암', () => {
|
||||
expect(codes(setAbutGround(rec, { strataRows: [{ name: '퇴적토', bottomEl: 35 }] }))).toContain('G01');
|
||||
expect(codes(setAbutGround(rec, { groundWaterOn: true }))).toContain('G02');
|
||||
expect(codes(setAbutGround(rec, { groundPosition: '하상' }))).toContain('G07');
|
||||
expect(codes(setAbutGround(rec, { groundPosition: '해상' }))).toContain('G13');
|
||||
// 연암 상면 22.000 − 1.0 × 0.6 = 21.4; the tip 19.295 is deep enough.
|
||||
expect(codes(setAbutGround(rec, { strataRows: rows }))).toEqual(['AI01']);
|
||||
const shallow = setAbutGround(rec, { strataRows: [{ name: '풍화암', bottomEl: 19.5 }, { name: '연암', bottomEl: 10 }] });
|
||||
expect(codes(shallow)).toContain('G04');
|
||||
expect(supportTopEl(rec.groundEl, rows)).toBe(22);
|
||||
expect(supportTopEl(rec.groundEl, [{ name: '퇴적토', bottomEl: 30 }])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('set: merges values, removes undefined, refuses non-numbers; the member row follows', () => {
|
||||
const a = setAbutGround(rec, { groundPosition: '하상', floodLevelEl: 36 });
|
||||
expect(setAbutGround(a, { floodLevelEl: undefined }).ground).toEqual({ groundPosition: '하상' });
|
||||
expect(() => setAbutGround(rec, { groundWaterEl: Number.NaN })).toThrow('숫자');
|
||||
const row = abutMemberRows(setAbutGround(rec, { strataRows: rows }), '—').find((r) => r.id === 'ground')!;
|
||||
expect(row.summary).toBe('육상 · 지층 3 · 지반고 EL.33.984');
|
||||
expect(row.changed).toBe(true);
|
||||
});
|
||||
|
||||
it('the project file keeps the 지반 values and refuses wrong ones', () => {
|
||||
expect(() => checkStoredAbutments([setAbutGround(rec, { groundPosition: '해상', highWaterEl: 35, strataRows: rows })])).not.toThrow();
|
||||
expect(() => checkStoredAbutments([{ ...rec, ground: { groundPosition: '산지' } as never }])).toThrow('지반');
|
||||
expect(() => checkStoredAbutments([{ ...rec, ground: { strataRows: [{ name: '연암', bottomEl: 'x' }] } as never }])).toThrow('지반');
|
||||
});
|
||||
|
||||
it('삽도: layers, water and the abutment section with EL. labels', () => {
|
||||
const m = membersOf(rec);
|
||||
const s = m.shape;
|
||||
const svg = abutGroundSectionSvg({
|
||||
ground: { groundEl: rec.groundEl, groundWaterOn: true, groundWaterEl: 31, strataRows: rows },
|
||||
footingTopEl: m.footingTopEl, footingBottomEl: m.footingBottomEl!, seatEl: rec.seatEl, parapetTopEl: m.parapetTopStartEl,
|
||||
bracketHeightM: s.bracketHeightM, wallFrontM: s.wallFrontM, wallBackM: s.wallBackM, parapetBackM: s.parapetBackM,
|
||||
footingFrontM: s.footingFrontM, footingBackM: s.footingBackM, pileRowsT: m.piles.map((r) => r.t), pileBottomEl: m.pileBottomEl,
|
||||
});
|
||||
for (const t of ['지반고 EL.33.984', '지하수위 EL.31.000', '연암층 상면 EL.22.000', '기초 하단 EL.40.895', '말뚝 선단 EL.19.295', '퇴적토', '풍화암']) expect(svg).toContain(t);
|
||||
});
|
||||
});
|
||||
@@ -42,10 +42,10 @@ describe('교대 부재 목록 (시안 ② 부재 · 교대)', () => {
|
||||
it('shows the slab width text and the ground EL as given', () => {
|
||||
const rows = abutMemberRows(base, '11.253 m');
|
||||
expect(rows.find((r) => r.id === 'slab')?.summary).toBe('매립형 · 하향 4 % · 폭 11.253 m');
|
||||
expect(rows.find((r) => r.id === 'ground')?.summary).toBe('지반고 33.984 m');
|
||||
expect(rows.find((r) => r.id === 'ground')?.summary).toBe('육상 · 지층 0 · 지반고 EL.33.984');
|
||||
});
|
||||
|
||||
it('every card but the ground takes input in this step (접속슬래브 2026-11-04)', () => {
|
||||
expect(ABUT_MEMBER_EDITABLE).toEqual({ bearing: true, shape: true, footing: true, wing: true, slab: true, ground: false });
|
||||
it('every card takes input in this step (접속슬래브 · 지반 2026-11-04)', () => {
|
||||
expect(ABUT_MEMBER_EDITABLE).toEqual({ bearing: true, shape: true, footing: true, wing: true, slab: true, ground: true });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user