- 데모 v2 이후 개발 PC의 변경을 옮김 (이 저장소의 이름 변경·README 정리는 유지) - 서버 기본 DEM, DEM 등고선, 속성 테이블 범위 선택, WNTR 교차 검증 시험 포함 - 새 라이브러리: ezdxf(Python), maplibre-contour(npm) - 변경 내역: docs/SPipe_패치내역_도면출력_2026-11-06.txt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
377 lines
20 KiB
TypeScript
377 lines
20 KiB
TypeScript
// 수리 종단면도 (hydraulic profile sheet) in the style of a Korean design drawing
|
|
// (e.g. 국사봉배수지 C1-003, 마전배수지 동수두도): ground / pipe / hydraulic grade
|
|
// lines over a station table (측점·절점번호·거리·추가거리·지반고·관저고·동수두·수압)
|
|
// with pipe runs (D, L) in the bottom row, facility symbols (배수지 ▽H.W.L,
|
|
// 감압밸브, 가압펌프) and pressures below the design minimum in red.
|
|
import { DrawingBuilder, legendTable, niceScale, panelHeader, sheetFrame, sheetHeading, SHEETS, type Drawing, type LegendStyle, type TitleInfo } from './drawing.ts';
|
|
|
|
export interface ProfileStation {
|
|
id: string;
|
|
/** Cumulative distance from the start (m). */
|
|
x: number;
|
|
ground: number;
|
|
invert: number;
|
|
/** Hydraulic head per series, same order as `series`. */
|
|
heads: number[];
|
|
pressure?: number;
|
|
kind?: 'reservoir' | 'tank' | 'junction';
|
|
/** Water levels for reservoirs / tanks (m). */
|
|
hwl?: number;
|
|
lwl?: number;
|
|
}
|
|
|
|
export interface ProfileRun {
|
|
fromX: number;
|
|
toX: number;
|
|
diameter: number; // mm
|
|
length: number; // m
|
|
roughness?: number;
|
|
flow?: number;
|
|
flowUnit?: string;
|
|
/** Hydraulic gradient (‰) along the run. */
|
|
gradient?: number;
|
|
}
|
|
|
|
export interface ProfileSeries {
|
|
name: string;
|
|
color: string;
|
|
dash?: 'dashed' | 'dotted';
|
|
}
|
|
|
|
/** Valves and pumps on the path (zero-length links between two stations). */
|
|
export interface ProfileFitting {
|
|
x: number;
|
|
kind: 'valve' | 'pump';
|
|
subtype?: string;
|
|
label: string;
|
|
}
|
|
|
|
export interface ProfileSheetInput {
|
|
sheet: keyof typeof SHEETS;
|
|
info: Omit<TitleInfo, 'scale'>;
|
|
stations: ProfileStation[];
|
|
runs: ProfileRun[];
|
|
series: ProfileSeries[];
|
|
/** Optional named points above the HGL (e.g. reservoirs). */
|
|
marks?: Array<{ x: number; text: string }>;
|
|
fittings?: ProfileFitting[];
|
|
/** Design minimum pressure (m): lower values are drawn red. */
|
|
minPressure?: number;
|
|
font?: string;
|
|
legendStyle?: LegendStyle;
|
|
legendBand?: string;
|
|
}
|
|
|
|
const fx = (v: number, d = 2) => (Number.isFinite(v) ? v.toFixed(d) : '-');
|
|
const fxc = (v: number, d = 2) => (Number.isFinite(v) ? v.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }) : '-');
|
|
const RED = '#dc2626';
|
|
|
|
function niceStep(range: number, target = 10): number {
|
|
const raw = range / target;
|
|
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
|
|
const n = raw / mag;
|
|
return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10) * mag;
|
|
}
|
|
|
|
/** Stations whose labels fit side by side (first, last and run ends win). */
|
|
export function labelledStations(xs: number[], priority: boolean[], minGap: number): boolean[] {
|
|
const keep = xs.map(() => false);
|
|
const taken: number[] = [];
|
|
const fits = (x: number) => taken.every((t) => Math.abs(t - x) >= minGap);
|
|
const order = xs.map((x, i) => i).sort((a, b) => Number(priority[b]) - Number(priority[a]) || a - b);
|
|
for (const i of [0, xs.length - 1, ...order]) {
|
|
if (keep[i] || !fits(xs[i])) continue;
|
|
keep[i] = true;
|
|
taken.push(xs[i]);
|
|
}
|
|
return keep;
|
|
}
|
|
|
|
/** Linear interpolation of a station value at distance x. */
|
|
function at(st: ProfileStation[], x: number, f: (s: ProfileStation) => number): number {
|
|
for (let i = 0; i < st.length - 1; i++) {
|
|
const a = st[i], c = st[i + 1];
|
|
if (x >= a.x && x <= c.x) {
|
|
const t = c.x > a.x ? (x - a.x) / (c.x - a.x) : 0;
|
|
return f(a) + (f(c) - f(a)) * t;
|
|
}
|
|
}
|
|
return f(st[x <= st[0].x ? 0 : st.length - 1]);
|
|
}
|
|
|
|
export function buildProfileDrawing(input: ProfileSheetInput): { drawing: Drawing; hScale: number; vScale: number } {
|
|
const sheet = SHEETS[input.sheet];
|
|
const st = input.stations;
|
|
if (st.length < 2) throw new Error('종단 경로에 절점이 2개 이상 필요합니다.');
|
|
const b = new DrawingBuilder(sheet.w, sheet.h, input.info.title);
|
|
const k = sheet.w / 420; // text/row sizes grow with the sheet
|
|
|
|
const total = st[st.length - 1].x - st[0].x;
|
|
const zs = st.flatMap((s) => [s.ground, s.invert, ...s.heads, s.hwl ?? NaN]).filter(Number.isFinite);
|
|
const zMin = Math.min(...zs), zMax = Math.max(...zs);
|
|
|
|
// Frame first so the scales use the real space.
|
|
const tmp = new DrawingBuilder(sheet.w, sheet.h, '');
|
|
const area = sheetFrame(tmp, { ...input.info, scale: '' });
|
|
const headW = 25 * k;
|
|
const rows: Array<{ label: string; h: number }> = [
|
|
{ label: '측 점', h: 8 },
|
|
{ label: '절 점 번 호', h: 11 },
|
|
{ label: '거 리 (m)', h: 10 },
|
|
{ label: '추가거리 (m)', h: 11 },
|
|
{ label: '지 반 고 (m)', h: 10 },
|
|
{ label: '관 저 고 (m)', h: 10 },
|
|
{ label: '동 수 두 (m)', h: 10 },
|
|
{ label: '수 압 (m)', h: 9 },
|
|
{ label: '관경 · 연장', h: 7 },
|
|
].map((r) => ({ ...r, h: r.h * k }));
|
|
const tableH = rows.reduce((a, r) => a + r.h, 0);
|
|
const gx0 = area.x0 + headW + 5 * k, gx1 = area.x1 - 14 * k;
|
|
const ty0 = area.y0, ty1 = ty0 + tableH;
|
|
const headingY = area.y1 - 16 * k;
|
|
const gy0 = ty1 + 6 * k, gy1 = headingY - 4 * k;
|
|
const hScale = niceScale(Math.max(total, 1), gx1 - gx0);
|
|
// Headroom above the highest line for facility labels.
|
|
const vScale = niceScale(Math.max(zMax - zMin, 1) * 1.35, gy1 - gy0, [10, 20, 25, 50, 100, 200, 250, 300, 500, 700, 1000, 2000, 2500, 5000, 10000]);
|
|
const tick = niceStep((gy1 - gy0) * vScale / 1000, 10);
|
|
// Do not drop below 0 m when everything is above sea level (wasted space).
|
|
const zBase0 = Math.floor((zMin - 0.05 * (zMax - zMin)) / tick) * tick;
|
|
const zBase = zMin >= 0 ? Math.max(0, zBase0) : zBase0;
|
|
const X = (x: number) => gx0 + ((x - st[0].x) * 1000) / hScale;
|
|
const Y = (z: number) => gy0 + ((z - zBase) * 1000) / vScale;
|
|
const zTop = zBase + ((gy1 - gy0) * vScale) / 1000;
|
|
|
|
const scaleTxt = `V = 1:${vScale.toLocaleString()} H = 1:${hScale.toLocaleString()}`;
|
|
sheetFrame(b, { ...input.info, scale: `V=1:${vScale.toLocaleString()} H=1:${hScale.toLocaleString()}` });
|
|
sheetHeading(b, area, input.info.title, scaleTxt);
|
|
|
|
// Elevation axes (both sides) + horizontal grid + datum.
|
|
for (const [ax, dir] of [[gx0, -1], [gx1, 1]] as const) {
|
|
b.line('AXIS', [[ax, gy0], [ax, gy1]], { width: 0.35 });
|
|
for (let z = zBase; z <= zTop + 1e-9; z += tick) {
|
|
const y = Y(z);
|
|
b.line('AXIS', [[ax, y], [ax + dir * 1.5 * k, y]], { width: 0.25 });
|
|
b.text('AXIS', ax + dir * 2.5 * k, y, fx(z, tick < 1 ? 1 : 0), 2 * k, { anchor: dir < 0 ? 'end' : 'start', vAnchor: 'middle' });
|
|
}
|
|
b.text('AXIS', ax + dir * 2.5 * k, gy1 + 3 * k, 'EL.(m)', 2 * k, { anchor: dir < 0 ? 'end' : 'start' });
|
|
}
|
|
for (let z = zBase + tick; z < zTop - 1e-9; z += tick) b.line('GRID', [[gx0, Y(z)], [gx1, Y(z)]], { width: 0.08, color: '#cbd5e1' });
|
|
b.line('AXIS', [[gx0, gy0], [gx1, gy0]], { width: 0.35 });
|
|
b.text('AXIS', gx0 + 1.5 * k, gy0 + 1.2 * k, `D.L = ${fx(zBase, tick < 1 ? 1 : 2)} m`, 2 * k, { bold: true });
|
|
|
|
// Station table.
|
|
// Priority stations: ends, facilities, diameter changes, fittings.
|
|
const dChange = (x: number) => {
|
|
const into = input.runs.find((r) => Math.abs(r.toX - x) < 1e-6), out = input.runs.find((r) => Math.abs(r.fromX - x) < 1e-6);
|
|
return !!into && !!out && into.diameter !== out.diameter;
|
|
};
|
|
const nearFitting = (x: number) => (input.fittings ?? []).some((f) => Math.abs(f.x - x) < 1e-3 + total * 1e-4);
|
|
const isRunEnd = st.map((s, i) => i === 0 || i === st.length - 1 || (s.kind !== undefined && s.kind !== 'junction') || dChange(s.x) || nearFitting(s.x));
|
|
const show = labelledStations(st.map((s) => X(s.x)), isRunEnd, 3.2 * k);
|
|
const rowTop: number[] = [];
|
|
{
|
|
let y = ty1;
|
|
for (const r of rows) { rowTop.push(y); y -= r.h; }
|
|
}
|
|
b.rect('TABLE', area.x0, ty0, gx1 - area.x0, tableH, { width: 0.45 });
|
|
rows.forEach((r, i) => {
|
|
if (i > 0) b.line('TABLE', [[area.x0, rowTop[i]], [gx1, rowTop[i]]], { width: 0.15 });
|
|
b.text('TABLE', area.x0 + headW / 2, rowTop[i] - r.h / 2, r.label, 2.2 * k, { anchor: 'middle', vAnchor: 'middle', bold: true });
|
|
});
|
|
b.line('TABLE', [[area.x0 + headW, ty0], [area.x0 + headW, ty1]], { width: 0.35 });
|
|
const minP = input.minPressure;
|
|
let prevShown = -1;
|
|
st.forEach((s, i) => {
|
|
if (!show[i]) return;
|
|
const x = X(s.x);
|
|
const topZ = Math.max(s.ground, s.invert);
|
|
b.line('GUIDE', [[x, rowTop[rows.length - 1]], [x, ty1]], { width: 0.08, color: '#94a3b8' });
|
|
b.line('GUIDE', [[x, gy0], [x, Y(Math.min(topZ, zTop))]], { width: 0.08, color: '#94a3b8', dash: 'dotted' });
|
|
const prev = prevShown >= 0 ? st[prevShown] : undefined;
|
|
prevShown = i;
|
|
const low = minP !== undefined && s.pressure !== undefined && s.kind !== 'reservoir' && s.kind !== 'tank' && s.pressure < minP;
|
|
const id = s.id.length > 11 ? `${s.id.slice(0, 10)}…` : s.id;
|
|
const vals: Array<[string, string?]> = [
|
|
[`No.${i}`],
|
|
[id],
|
|
[fxc(prev ? s.x - prev.x : 0, 1)],
|
|
[fxc(s.x - st[0].x, 1)],
|
|
[fx(s.ground)],
|
|
[fx(s.invert)],
|
|
[s.heads.length ? fx(s.heads[0]) : '-'],
|
|
[s.pressure === undefined ? '-' : fx(s.pressure), low ? RED : undefined],
|
|
];
|
|
vals.forEach(([v, color], r) => {
|
|
b.text('TABLE', x, rowTop[r] - rows[r].h / 2, v, (r <= 1 ? 1.8 : 1.65) * k, { anchor: 'middle', vAnchor: 'middle', rotate: 90, color, bold: r === 0 || !!color });
|
|
});
|
|
});
|
|
|
|
// Bottom row: one bracket per run of equal diameter.
|
|
const merged: ProfileRun[] = [];
|
|
for (const r of input.runs) {
|
|
const last = merged[merged.length - 1];
|
|
if (last && last.diameter === r.diameter && Math.abs(last.toX - r.fromX) < 1e-6) merged[merged.length - 1] = { ...last, toX: r.toX, length: last.length + r.length };
|
|
else merged.push({ ...r });
|
|
}
|
|
const rr = rows.length - 1;
|
|
const remY = rowTop[rr] - rows[rr].h / 2;
|
|
for (const r of merged) {
|
|
const a = X(r.fromX), c = X(r.toX);
|
|
const h = rows[rr].h;
|
|
b.line('RUN', [[a, remY - h * 0.28], [a, remY + h * 0.28]], { width: 0.25 });
|
|
b.line('RUN', [[c, remY - h * 0.28], [c, remY + h * 0.28]], { width: 0.25 });
|
|
const t = `D${r.diameter.toFixed(0)}mm L=${fxc(r.length, 1)}m`;
|
|
const tw = t.length * 0.62 * 1.8 * k;
|
|
if (c - a > tw + 4 * k) {
|
|
b.line('RUN', [[a, remY], [(a + c) / 2 - tw / 2 - 1 * k, remY]], { width: 0.15 });
|
|
b.line('RUN', [[(a + c) / 2 + tw / 2 + 1 * k, remY], [c, remY]], { width: 0.15 });
|
|
b.text('RUN', (a + c) / 2, remY, t, 1.8 * k, { anchor: 'middle', vAnchor: 'middle' });
|
|
} else {
|
|
b.line('RUN', [[a, remY], [c, remY]], { width: 0.15 });
|
|
if (c - a > 7 * k) b.text('RUN', (a + c) / 2, remY, `D${r.diameter.toFixed(0)}`, 1.5 * k, { anchor: 'middle', vAnchor: 'middle' });
|
|
}
|
|
}
|
|
|
|
// Lines: ground, pipe invert (+ crown when the bore is visible), HGL series.
|
|
const P = (f: (s: ProfileStation) => number) => st.map((s) => [X(s.x), Y(f(s))] as [number, number]).filter(([, y]) => Number.isFinite(y));
|
|
b.line('GROUND', P((s) => s.ground), { width: 0.4, color: '#7c4a1e' });
|
|
// Ground hatch ticks under the ground line (every ~4 mm on paper).
|
|
{
|
|
const step = 4 * k;
|
|
for (let px = gx0 + step / 2; px < X(st[st.length - 1].x); px += step) {
|
|
const xm = st[0].x + ((px - gx0) * hScale) / 1000;
|
|
const yg = Y(at(st, xm, (s) => s.ground));
|
|
if (!Number.isFinite(yg) || yg - 1.6 * k < gy0) continue;
|
|
b.line('GROUND', [[px, yg], [px - 1.1 * k, yg - 1.6 * k]], { width: 0.1, color: '#a16207' });
|
|
}
|
|
}
|
|
const showCrown = merged.some((r) => (r.diameter / 1000) * 1000 / vScale >= 0.45);
|
|
b.line('PIPE', P((s) => s.invert), { width: 0.5, color: '#111111' });
|
|
if (showCrown) {
|
|
for (const r of input.runs) {
|
|
const d = r.diameter / 1000;
|
|
b.line('PIPE', [[X(r.fromX), Y(at(st, r.fromX, (s) => s.invert) + d)], [X(r.toX), Y(at(st, r.toX, (s) => s.invert) + d)]], { width: 0.3, color: '#111111' });
|
|
}
|
|
}
|
|
input.series.forEach((sr, i) => {
|
|
b.line(`HGL_${i + 1}`, P((s) => s.heads[i]), { width: 0.45, color: sr.color, dash: sr.dash });
|
|
});
|
|
// Inline name on the first HGL, along its longest early segment.
|
|
if (input.series.length) {
|
|
let best = -1, bi = -1;
|
|
for (let i = 0; i < st.length - 1; i++) {
|
|
const L = X(st[i + 1].x) - X(st[i].x);
|
|
if (Number.isFinite(st[i].heads[0]) && Number.isFinite(st[i + 1].heads[0]) && L > best) { best = L; bi = i; }
|
|
if (X(st[i + 1].x) > (gx0 + gx1) / 2 && best > 25 * k) break;
|
|
}
|
|
if (bi >= 0 && best > 22 * k) {
|
|
const ax = X(st[bi].x), ay = Y(st[bi].heads[0]), cx = X(st[bi + 1].x), cy = Y(st[bi + 1].heads[0]);
|
|
const ang = (Math.atan2(cy - ay, cx - ax) * 180) / Math.PI;
|
|
const t = 0.3;
|
|
b.text('NOTE', ax + (cx - ax) * t, ay + (cy - ay) * t + 1.2 * k, `동수경사선 (${input.series[0].name})`, 2 * k, { rotate: ang, color: input.series[0].color, bold: true });
|
|
}
|
|
}
|
|
|
|
// Run annotations above the first HGL: C, Q, I.
|
|
for (const r of merged) {
|
|
const a = X(r.fromX), c = X(r.toX);
|
|
if (c - a < 26 * k) continue;
|
|
const mid = (r.fromX + r.toX) / 2;
|
|
const h = at(st, mid, (s) => s.heads[0]);
|
|
if (!Number.isFinite(h)) continue;
|
|
const y = Math.min(Y(h) + 3.5 * k, gy1 - 2 * k);
|
|
const parts = [r.roughness !== undefined ? `C=${r.roughness.toFixed(0)}` : '', r.flow !== undefined ? `Q=${fxc(r.flow, 1)}${r.flowUnit ?? ''}` : '', r.gradient !== undefined ? `I=${r.gradient.toFixed(2)}‰` : ''].filter(Boolean);
|
|
if (parts.length) b.text('NOTE', (a + c) / 2 + 6 * k, y, parts.join(' '), 1.8 * k, { anchor: 'middle', color: '#1e3a8a' });
|
|
}
|
|
|
|
// Leader labels (60°) above the highest line at x.
|
|
const labelTops: number[] = [];
|
|
const leader = (x: number, fromY: number, text: string, color = '#0f172a') => {
|
|
const px = X(x);
|
|
const topZ = Math.max(at(st, x, (s) => Math.max(s.ground, ...s.heads.filter(Number.isFinite), s.hwl ?? -Infinity)));
|
|
const size = 2.1 * k;
|
|
const rise = text.length * size * 0.8 * Math.sin(Math.PI / 3);
|
|
let base = Math.min(Y(topZ) + 7 * k, gy1 - rise);
|
|
base = Math.max(base, fromY + 3 * k);
|
|
// nudge up when an earlier label sits right next to this one
|
|
for (const t of labelTops) if (Math.abs(t - px) < size * 1.6) base = Math.min(base + size * 1.4, gy1 - 2 * k);
|
|
labelTops.push(px);
|
|
b.line('NOTE', [[px, fromY], [px, base]], { width: 0.15, color });
|
|
b.line('NOTE', [[px, base], [px + size * 0.5, base + size * 0.87]], { width: 0.15, color });
|
|
b.text('NOTE', px + size * 0.4, base + size * 0.5, text, size, { rotate: 60, color, bold: true });
|
|
};
|
|
|
|
// Reservoirs / tanks: ▽ at the water level and a label.
|
|
const tri = (layer: string, cx: number, y: number, s: number, color: string) => {
|
|
b.fill(layer, [[cx - s, y + s * 1.15], [cx + s, y + s * 1.15], [cx, y]], color);
|
|
};
|
|
for (const s of st) {
|
|
if (s.kind !== 'reservoir' && s.kind !== 'tank') continue;
|
|
const x = X(s.x);
|
|
const hwl = s.hwl ?? s.ground;
|
|
const y = Y(hwl);
|
|
if (s.kind === 'tank' && s.lwl !== undefined) {
|
|
const w = 5 * k;
|
|
b.rect('FACILITY', x - w / 2, Y(at(st, s.x, (q) => q.invert)), w, y - Y(at(st, s.x, (q) => q.invert)) + 1.5 * k, { width: 0.35 });
|
|
b.line('FACILITY', [[x - w / 2 - 1.5 * k, Y(s.lwl)], [x + w / 2 + 1.5 * k, Y(s.lwl)]], { width: 0.2, color: '#0369a1', dash: 'dashed' });
|
|
}
|
|
b.line('FACILITY', [[x - 4 * k, y], [x + 4 * k, y]], { width: 0.35, color: '#0369a1' });
|
|
b.line('FACILITY', [[x - 2.6 * k, y - 0.8 * k], [x + 2.6 * k, y - 0.8 * k]], { width: 0.2, color: '#0369a1' });
|
|
tri('FACILITY', x, y, 1.3 * k, '#0369a1');
|
|
b.text('FACILITY', x + 4.5 * k, y - 1.2 * k, `H.W.L ${fx(hwl)}`, 1.9 * k, { vAnchor: 'top', color: '#0369a1', bold: true });
|
|
const name = (input.marks ?? []).find((m) => Math.abs(m.x - s.x) < 1e-6)?.text ?? `${s.kind === 'reservoir' ? '배수지' : '탱크'} ${s.id}`;
|
|
leader(s.x, y + 2 * k, name);
|
|
}
|
|
for (const m of input.marks ?? []) {
|
|
if (st.some((s) => (s.kind === 'reservoir' || s.kind === 'tank') && Math.abs(s.x - m.x) < 1e-6)) continue;
|
|
leader(m.x, Y(at(st, m.x, (s) => Math.max(s.ground, ...s.heads.filter(Number.isFinite)))) + 1 * k, m.text);
|
|
}
|
|
|
|
// Valves (bow-tie on the pipe, PRV filled) and pumps (circle + triangle).
|
|
for (const f of input.fittings ?? []) {
|
|
const x = X(f.x), y = Y(at(st, f.x, (s) => s.invert));
|
|
const s = 1.5 * k;
|
|
if (f.kind === 'valve') {
|
|
const filled = f.subtype === 'PRV' || f.subtype === 'PSV' || f.subtype === 'PBV';
|
|
const L: Array<[number, number]> = [[x - s, y - s * 0.75], [x - s, y + s * 0.75], [x, y]];
|
|
const R: Array<[number, number]> = [[x + s, y - s * 0.75], [x + s, y + s * 0.75], [x, y]];
|
|
if (filled) { b.fill('VALVE', L, '#111111'); b.fill('VALVE', R, '#111111'); }
|
|
b.line('VALVE', L, { width: 0.25, closed: true });
|
|
b.line('VALVE', R, { width: 0.25, closed: true });
|
|
} else {
|
|
b.circle('PUMP', x, y, s * 1.1, { fill: '#ffffff', color: '#111111', width: 0.3 });
|
|
b.fill('PUMP', [[x - s * 0.45, y - s * 0.6], [x - s * 0.45, y + s * 0.6], [x + s * 0.65, y]], '#111111');
|
|
}
|
|
const hgl = at(st, f.x, (q) => q.heads[0]);
|
|
leader(f.x, Math.max(y, Number.isFinite(hgl) ? Y(hgl) : y) + 2 * k, f.label, f.kind === 'valve' ? '#7c2d12' : '#14532d');
|
|
}
|
|
|
|
// Legend (top-right of the graph): plain framed panel.
|
|
const entries: Array<{ name: string; color: string; dash?: 'dashed' | 'dotted'; w: number; sym?: 'tri' | 'valve' | 'pump' | 'red' }> = [
|
|
{ name: '지반선', color: '#7c4a1e', w: 0.4 },
|
|
{ name: showCrown ? '관로 (관저·관정)' : '관저고', color: '#111111', w: 0.5 },
|
|
...input.series.map((s) => ({ name: `동수경사선 (${s.name})`, color: s.color, dash: s.dash, w: 0.45 })),
|
|
...(st.some((s) => s.kind === 'reservoir' || s.kind === 'tank') ? [{ name: '배수지 수위 (H.W.L)', color: '#0369a1', w: 0, sym: 'tri' as const }] : []),
|
|
...((input.fittings ?? []).some((f) => f.kind === 'valve') ? [{ name: '밸브 (감압 등: 채움)', color: '#111111', w: 0, sym: 'valve' as const }] : []),
|
|
...((input.fittings ?? []).some((f) => f.kind === 'pump') ? [{ name: '가압펌프', color: '#111111', w: 0, sym: 'pump' as const }] : []),
|
|
...(minP !== undefined && st.some((s) => s.pressure !== undefined) ? [{ name: `수압 ${minP}m 미만 (붉은 글씨)`, color: RED, w: 0, sym: 'red' as const }] : []),
|
|
];
|
|
const lw = 62 * k;
|
|
type E = (typeof entries)[number];
|
|
const sym = (en: E) => (sx: number, y: number) => {
|
|
const ex = sx + 10 * k;
|
|
if (en.sym === 'tri') { b.line('LEGEND', [[sx + 1 * k, y], [ex - 1 * k, y]], { width: 0.3, color: en.color }); tri('LEGEND', (sx + ex) / 2, y, 1.1 * k, en.color); }
|
|
else if (en.sym === 'valve') { const c = (sx + ex) / 2, s2 = 1.3 * k; b.fill('LEGEND', [[c - s2, y - s2 * 0.75], [c - s2, y + s2 * 0.75], [c, y]], '#111111'); b.fill('LEGEND', [[c + s2, y - s2 * 0.75], [c + s2, y + s2 * 0.75], [c, y]], '#111111'); }
|
|
else if (en.sym === 'pump') { const c = (sx + ex) / 2; b.circle('LEGEND', c, y, 1.4 * k, { fill: '#ffffff', color: '#111111', width: 0.3 }); b.fill('LEGEND', [[c - 0.6 * k, y - 0.8 * k], [c - 0.6 * k, y + 0.8 * k], [c + 0.9 * k, y]], '#111111'); }
|
|
else if (en.sym === 'red') b.text('LEGEND', (sx + ex) / 2, y, '12.34', 1.8 * k, { anchor: 'middle', vAnchor: 'middle', color: RED, bold: true });
|
|
else b.line('LEGEND', [[sx, y], [ex, y]], { width: en.w, color: en.color, dash: en.dash });
|
|
};
|
|
const leg = legendTable(b, 'LEGEND', [
|
|
{ name: '선형', rows: entries.filter((en) => !en.sym).map((en) => ({ label: en.name, draw: sym(en) })) },
|
|
{ name: '기호', rows: entries.filter((en) => en.sym).map((en) => ({ label: en.name, draw: sym(en) })) },
|
|
], { w: lw, rowH: 4.2 * k, style: input.legendStyle ?? 'table', band: input.legendBand });
|
|
if (leg.h) leg.draw(gx1 - lw - 3 * k, gy1 - 6 * k - leg.h);
|
|
|
|
return { drawing: b.build(input.font), hScale, vScale };
|
|
}
|