feat(abut): 교대 지반·지층 카드 — 교각과 같은 위치 · 수위 · 지층 행 입력, 교대 단면 삽도, 검사

- 위치 (육상 · 하상 · 해상)와 위치별 수위 입력, 지층 행 표 (교각과 공용 strataRowsEditor), 지반 모델 지층 불러오기
- 검사: 교각과 같은 G01–G14, 말뚝 근입 G04, 지반고 < 저판 하면이면 정보 AI01
- 삽도: 지층 · 수위 띠를 교각과 공용으로 빼고, 교대축 직각 단면(저판 · 벽체 · 흉벽 · 말뚝 열)을 EL. 표기로 그림
- 자동 값: 지반고 (엑셀 입력), 연암층 상면 EL, 지층 수. 프로젝트 파일 저장 · 검증

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-11-04 10:36:23 +09:00
1 parent 9835fb42b3
commit 59c2607418
12 files changed
+527 -135

No files matched your search

+132 -61
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}