도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, DXF(AutoCAD 2018)

- 데모 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>
This commit is contained in:
SPipe EngineeringandClaude Opus 5.5 committed 2026-11-06 15:03:21 +09:00
1 parent 14f07181db
commit e3f386addf
33 files changed
+3454 -482

No files matched your search

+36 -2
View File
@@ -81,6 +81,7 @@ import { CurveEditorModal } from './CurveEditorModal';
import { ElementExtrasEditor } from './ElementExtrasEditor';
import { DemandTableModal } from './DemandTableModal';
import { BackupModal } from './BackupModal';
import { DrawingExportModal, type DrawingKind } from './DrawingExportModal';
import { saveBackup } from './backupStore';
import { loadEditOptions, recalcPipeLengths } from './networkTools';
import { FrequencyPanel, MultiSeriesPanel, type SeriesItem } from './ResultCharts';
@@ -247,8 +248,10 @@ export default function App() {
const [showCurveEditor, setShowCurveEditor] = useState(false);
const [showDemandTable, setShowDemandTable] = useState(false);
const [showBackups, setShowBackups] = useState(false);
const [drawingTarget, setDrawingTarget] = useState<{ kind: DrawingKind; from?: string; to?: string } | null>(null);
const [rasterOverlays, setRasterOverlays] = useState<import('./types').RasterOverlay[]>([]);
const [view3d, setView3d] = useState<import('./types').View3D | undefined>(undefined);
const [demContours, setDemContours] = useState<import('./types').DemContours | null>(null);
const [mapAnnotations, setMapAnnotations] = useState<import('./types').MapAnnotation[] | null>(null);
const [verificationSection, setVerificationSection] = useState<'comparison' | 'topology' | 'options' | 'patterns' | 'controls' | 'diff' | 'scenarios'>('comparison');
const [batchRunProgress, setBatchRunProgress] = useState<{ done: number; total: number } | null>(null);
@@ -929,6 +932,7 @@ export default function App() {
onExportShapefile={handleExportShapefile}
onShowInpText={() => setShowInpModal(true)}
onOpenAttributeTable={() => toggleBottomPanel('table')}
onOpenDrawing={networkModel ? () => setDrawingTarget({ kind: 'plan' }) : undefined}
/>
)}
@@ -1079,13 +1083,15 @@ export default function App() {
annotations={mapAnnotations ?? undefined}
rasterOverlays={rasterOverlays}
view3d={view3d}
demContours={demContours}
onLegendOverride={setLegendOverride}
onSelectTarget={(t) => {
setSelectedTarget(t);
if (t) setActiveTab('inspector');
}}
onNodeDragEnd={handleNodeDragEnd}
onVerticesChange={(linkId, verts, message) => {
// 꺾임점 핸들은 [편집] 모드에서만 (탐색 중 선택한 관로가 핸들로 뒤덮이지 않게).
onVerticesChange={appMode !== 'edit' ? undefined : (linkId, verts, message) => {
if (!networkModel) return;
const vertices = { ...(networkModel.vertices ?? {}), [linkId]: verts };
if (!verts.length) delete vertices[linkId];
@@ -1294,7 +1300,7 @@ export default function App() {
{/* TAB 1: Inspector */}
{activeTab === 'inspector' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div className="inspector-pane" style={{ display: 'flex', flexDirection: 'column', gap: '12px', minWidth: 0 }}>
{/* CAD Multi-Selection Batch Property Editor */}
{networkModel && (selectionCounts.nodes + selectionCounts.links > 1) && (
<BatchPropertyEditor
@@ -1547,6 +1553,7 @@ export default function App() {
setSelectedTarget({ type: 'node', id });
requestMapFocus({ target: { type: 'node', id } });
}}
onOpenDrawing={(from, to) => setDrawingTarget({ kind: 'profile', from, to })}
/>
)}
@@ -1661,6 +1668,8 @@ export default function App() {
rasterOverlays={rasterOverlays}
view3d={view3d}
onView3d={setView3d}
demContours={demContours}
onDemContours={setDemContours}
onSetUserLayers={setUserLayers}
calibrationData={calibrationData}
/>
@@ -2109,6 +2118,31 @@ export default function App() {
{showCurveEditor && networkModel && (
<CurveEditorModal model={networkModel} onApply={handleApplyModel} onClose={() => setShowCurveEditor(false)} />
)}
{drawingTarget && networkModel && (
<DrawingExportModal
model={networkModel}
result={wasmResult}
crs={crs}
units={units}
flowUnit={displayUnits.flow}
timeIndex={timeStepIndex}
timeLabel={(() => {
const s = wasmResult?.timeSteps?.[timeStepIndex] ?? 0;
return `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}`;
})()}
minPressure={minPressureThreshold}
maxVelocity={maxVelocityThreshold}
userLayers={userLayers}
selection={{
nodes: [...new Set([...multiSelection.nodes, ...(selectedTarget?.type === 'node' ? [selectedTarget.id] : [])])],
links: [...new Set([...multiSelection.links, ...(selectedTarget?.type === 'link' ? [selectedTarget.id] : [])])],
}}
initialKind={drawingTarget.kind}
initialFrom={drawingTarget.from}
initialTo={drawingTarget.to}
onClose={() => setDrawingTarget(null)}
/>
)}
{showBackups && (
<BackupModal
onBackupNow={async () => {
+36 -6
View File
@@ -1,11 +1,11 @@
// 분석 tab > 지형(DEM): load a DEM (or build one from points), show it on the
// map, fill node elevations as ground or pipe-invert level, check elevations
// against it, or fetch coarse internet elevations when there is no DEM file.
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Crosshair, Upload } from 'lucide-react';
import type { AnalysisPanelProps } from './AnalysisPanel';
import type { RasterOverlay, UserGisLayer, View3D } from './types';
import { demFromPoints, demHydro, demPreviewUrl, sampleDem, uploadDem, type DemInfo } from './api';
import type { DemContours, RasterOverlay, UserGisLayer, View3D } from './types';
import { demFromPoints, demHydro, demPreviewUrl, listBaseDems, sampleDem, uploadDem, type DemInfo } from './api';
import { applyGroundElevations, checkElevations, crsProj4, type ElevationMode, type ElevationOptions } from './demTools';
import { lookupApproxElevations } from './mapServices';
import { modelToLngLat } from './crs';
@@ -16,6 +16,8 @@ export interface DemSectionProps {
view3d?: View3D;
onView3d?: (v: View3D) => void;
onAddUserLayer?: (layer: UserGisLayer) => void;
demContours?: DemContours | null;
onDemContours?: (c: DemContours | null) => void;
}
const box: React.CSSProperties = { border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: 8, display: 'flex', flexDirection: 'column', gap: 6 };
@@ -25,7 +27,7 @@ const muted: React.CSSProperties = { color: 'var(--text-muted)' };
const th: React.CSSProperties = { padding: '3px 5px', textAlign: 'right', background: 'var(--bg-subtle)', position: 'sticky', top: 0 };
const td: React.CSSProperties = { padding: '2px 5px', textAlign: 'right', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-mono)' };
export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ model, crs, userLayers, selectedNodeIds, cache, setCache, onApplyModel, onFocus, onSelectElements, onRasterOverlays, view3d, onView3d, onAddUserLayer }) => {
export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ model, crs, userLayers, selectedNodeIds, cache, setCache, onApplyModel, onFocus, onSelectElements, onRasterOverlays, view3d, onView3d, onAddUserLayer, demContours, onDemContours }) => {
const [minArea, setMinArea] = useState('0.1');
const addLayer = (name: string, fc: import('geojson').FeatureCollection, color: string) => onAddUserLayer?.({
id: `layer-${Date.now()}`, name, fileName: `${name}.geojson`, featureCollection: fc, featureCount: fc.features.length,
@@ -78,6 +80,17 @@ export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ mod
const showDem = (info: DemInfo, visible = shown, op = opacity) => {
onRasterOverlays?.((prev) => [...prev.filter((r) => r.id !== 'dem'), { id: 'dem', name: `DEM ${info.name}`, url: `${demPreviewUrl(info.id)}?v=${info.id}`, corners: info.corners, opacity: op, visible }]);
};
const [baseDems, setBaseDems] = useState<DemInfo[]>([]);
const [contourStep, setContourStep] = useState(demContours?.interval ?? 10);
useEffect(() => {
listBaseDems().then(setBaseDems).catch(() => setBaseDems([]));
}, []);
/** Network extent + margin (model units): hydrology on country-wide DEMs runs on this window. */
const networkBox = (margin = 2000): number[] => {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of Object.values(model.nodes)) { x0 = Math.min(x0, n.x); y0 = Math.min(y0, n.y); x1 = Math.max(x1, n.x); y1 = Math.max(y1, n.y); }
return [x0 - margin, y0 - margin, x1 + margin, y1 + margin];
};
const setDem = (info: DemInfo) => {
setCache('demInfo', info);
setCache('demCheck', undefined);
@@ -104,6 +117,15 @@ export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ mod
<div style={box}>
<b>DEM</b>
{baseDems.length > 0 && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{baseDems.map((b) => (
<button key={b.id} className={dem?.id === b.id ? 'btn btn-primary' : 'btn btn-secondary'} style={btn} title="서버에 등록된 DEM (업로드 없이 바로 사용)" onClick={() => setDem(b)}>
서버 DEM: {b.name}
</button>
))}
</div>
)}
<label className="btn btn-secondary" style={{ ...btn, alignSelf: 'flex-start', display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<Upload size={11} />DEM 파일 불러오기 (GeoTIFF·ASC·IMG, zip)
<input type="file" accept=".tif,.tiff,.asc,.img,.zip,.dem,.bil,.flt,.hgt" style={{ display: 'none' }} onChange={(e) => {
@@ -119,6 +141,14 @@ export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ mod
<label><input type="checkbox" checked={shown} onChange={(e) => { setShown(e.target.checked); showDem(dem, e.target.checked); }} /> 지도에 표시</label>
<input type="range" min={0.1} max={1} step={0.05} value={opacity} aria-label="DEM 투명도" onChange={(e) => { const v = Number(e.target.value); setOpacity(v); showDem(dem, shown, v); }} />
</div>
{onDemContours && (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label><input type="checkbox" checked={demContours?.demId === dem.id} onChange={(e) => onDemContours(e.target.checked ? { demId: dem.id, interval: contourStep, maxzoom: Math.max(8, Math.min(16, Math.round(Math.log2(156543 / Math.max(dem.resolution[0], 1))))) } : null)} /> 지도에 등고선</label>
<select value={contourStep} aria-label="등고선 간격" onChange={(e) => { const v = Number(e.target.value); setContourStep(v); if (demContours?.demId === dem.id) onDemContours({ ...demContours, interval: v }); }} style={{ ...inputStyle, width: 90 }}>
{[1, 2, 5, 10, 20, 50].map((v) => <option key={v} value={v}>{v} m 간격</option>)}
</select>
</div>
)}
</>
) : (
<span style={muted}>국토지리정보원 수치표고모델 등 DEM 파일을 넣으세요. 좌표계 정보(.prj 또는 GeoTIFF)가 있어야 합니다. 서버에 보관되어 다른 화면을 오가도 유지됩니다.</span>
@@ -217,13 +247,13 @@ export const DemSection: React.FC<AnalysisPanelProps & DemSectionProps> = ({ mod
<div style={{ display: 'flex', gap: 6, alignItems: 'end', flexWrap: 'wrap' }}>
<label style={{ width: 130 }}>최소 유역 (km²)<input type="number" step="0.05" value={minArea} onChange={(e) => setMinArea(e.target.value)} style={inputStyle} /></label>
<button className="btn btn-secondary" style={btn} disabled={!!busy} onClick={() => void task('하천 추출 중', async () => {
const fc = await demHydro(dem.id, { op: 'streams', minAreaKm2: Number(minArea) || 0.1 });
const fc = await demHydro(dem.id, { op: 'streams', minAreaKm2: Number(minArea) || 0.1, bbox: networkBox(), crs: proj });
if (!fc.features.length) throw new Error('조건에 맞는 하천이 없습니다. 최소 유역을 줄이세요.');
addLayer(`하천 (≥${minArea} km²)`, fc, '#0284c7');
})}>하천선 추출</button>
<button className="btn btn-secondary" style={btn} disabled={!!busy || !selectedNodeIds.length} title="선택한 절점마다 상류 유역" onClick={() => void task('유역 계산 중', async () => {
const ids = selectedNodeIds.filter((id) => model.nodes[id]);
const fc = await demHydro(dem.id, { op: 'watershed', points: ids.map((id) => [model.nodes[id].x, model.nodes[id].y]), crs: proj, snap: 50 });
const fc = await demHydro(dem.id, { op: 'watershed', points: ids.map((id) => [model.nodes[id].x, model.nodes[id].y]), crs: proj, snap: 50, bbox: networkBox(5000) });
fc.features.forEach((f) => { if (f.properties) f.properties.node = ids[Number(f.properties.point)]; });
if (!fc.features.length) throw new Error('선택 절점이 DEM 범위 밖입니다.');
addLayer(`유역 (${ids.slice(0, 3).join(', ')}${ids.length > 3 ? ' …' : ''})`, fc, '#0d9488');
+395
View File
@@ -0,0 +1,395 @@
// 도면 출력: 수리 종단면도 / 관망도 sheets with live preview, saved as PDF
// (browser print), DXF (Korean code page via the API) or SVG.
import React, { useEffect, useMemo, useState } from 'react';
import { X, Printer, Download } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import type { SimulationResult, UserGisLayer } from './types';
import { lngLatToModel, type CRS } from './crs';
import { findProfilePath } from './profilePath';
import { DEFAULT_FONT, DRAWING_FONTS, mergeDrawings, SHEETS, toDxf, toSvg, type Drawing, type TitleInfo } from './drawing';
import { buildProfileDrawing } from './profileDrawing';
import { buildNetworkDrawing, planLayout, tileNeighbours } from './networkDrawing';
import { PALETTE_KEYS, PLAN_PALETTES, planInput, profileInput, SERIES_LABEL, type Conv, type PlanColorMode, type PlanPalette, type SeriesKey } from './drawingData';
import type { LegendStyle } from './drawing';
const LEGEND_BANDS = [
{ color: '#c8c8c8', label: '회색 (기본)' },
{ color: '#d6e4f0', label: '하늘색' },
{ color: '#dcebd5', label: '연두색' },
{ color: '#efe6d2', label: '베이지' },
{ color: '#ffffff', label: '없음 (흰색)' },
];
export type DrawingKind = 'profile' | 'plan';
interface Props {
model: NetworkModel;
result: SimulationResult | null;
crs: CRS;
units: Conv;
flowUnit: string;
timeIndex: number;
timeLabel: string;
minPressure: number;
maxVelocity: number;
userLayers: UserGisLayer[];
selection: { nodes: string[]; links: string[] };
initialKind?: DrawingKind;
initialFrom?: string;
initialTo?: string;
onClose: () => void;
}
const INFO_KEY = 'spipe_title_info';
const inp: React.CSSProperties = { height: 24, fontSize: 12, width: '100%' };
const lab: React.CSSProperties = { display: 'grid', gap: 2, fontSize: 11, color: 'var(--text-secondary)' };
function loadInfo(): Omit<TitleInfo, 'scale' | 'title'> {
try {
return { project: '', number: '', client: '', company: '', date: new Date().toISOString().slice(0, 7).replace('-', '.'), ...JSON.parse(localStorage.getItem(INFO_KEY) || '{}') };
} catch {
return { project: '', number: '', client: '', company: '', date: '' };
}
}
function download(blob: Blob, name: string) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
export const DrawingExportModal: React.FC<Props> = (p) => {
const { model, result } = p;
const [kind, setKind] = useState<DrawingKind>(p.initialKind ?? 'profile');
const [sheet, setSheet] = useState<keyof typeof SHEETS>('A3');
const [info, setInfo] = useState(loadInfo);
const [title, setTitle] = useState('');
// profile
const [from, setFrom] = useState(p.initialFrom ?? p.selection.nodes[0] ?? '');
const [to, setTo] = useState(p.initialTo ?? p.selection.nodes[1] ?? '');
const [elevationIs, setElevationIs] = useState<'invert' | 'ground'>('invert');
const [depth, setDepth] = useState('1.2');
const [series, setSeries] = useState<SeriesKey[]>(result ? ['now', 'max'] : []);
// plan
const [mode, setMode] = useState<PlanColorMode>(result ? 'hydraulic' : 'diameter');
const [pipeLabels, setPipeLabels] = useState<'none' | 'D' | 'DL' | 'IDDL'>('DL');
const [nodeLabels, setNodeLabels] = useState<'none' | 'sources' | 'all'>('sources');
const [maxPressure, setMaxPressure] = useState('70');
const [onlySelection, setOnlySelection] = useState(false);
const [withBackground, setWithBackground] = useState(true);
const [layout, setLayout] = useState<'single' | 'split'>('split');
const [planScale, setPlanScale] = useState<string>('auto');
const [sheetNaming, setSheetNaming] = useState<'serial' | 'grid'>('serial');
const [pressureTags, setPressureTags] = useState<'none' | 'kgf' | 'm'>(() => {
try { const v = localStorage.getItem('spipe_drawing_tags'); return v === 'm' || v === 'none' ? v : 'kgf'; } catch { return 'kgf'; }
});
const [font, setFont] = useState<string>(() => { try { return localStorage.getItem('spipe_drawing_font') || DEFAULT_FONT; } catch { return DEFAULT_FONT; } });
// Legend look and class colours (remembered between sessions).
const [legend, setLegend] = useState<{ style: LegendStyle; band: string; preset: string; colors: PlanPalette }>(() => {
const def = { style: 'table' as LegendStyle, band: '#c8c8c8', preset: 'standard', colors: PLAN_PALETTES.standard.colors };
try { const v = JSON.parse(localStorage.getItem('spipe_drawing_legend') || 'null'); return v && v.colors ? { ...def, ...v, colors: { ...def.colors, ...v.colors } } : def; } catch { return def; }
});
const [showColors, setShowColors] = useState(false);
const [page, setPage] = useState(0);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
useEffect(() => {
try { localStorage.setItem(INFO_KEY, JSON.stringify(info)); } catch { /* private mode */ }
}, [info]);
useEffect(() => {
try { localStorage.setItem('spipe_drawing_font', font); } catch { /* private mode */ }
}, [font]);
useEffect(() => {
try { localStorage.setItem('spipe_drawing_legend', JSON.stringify(legend)); } catch { /* private mode */ }
}, [legend]);
useEffect(() => {
try { localStorage.setItem('spipe_drawing_tags', pressureTags); } catch { /* private mode */ }
}, [pressureTags]);
const effectiveTitle = title || (kind === 'profile' ? '관로 수리 종단면도' : '관망도');
const nodeIds = useMemo(() => Object.keys(model.nodes), [model]);
const built = useMemo((): { drawings: Drawing[]; pages: string[]; note: string } | { error: string } => {
try {
const fullInfo = { ...info, title: effectiveTitle };
if (kind === 'profile') {
if (!model.nodes[from] || !model.nodes[to]) return { error: '시작·끝 절점 ID를 입력하세요 (지도에서 절점 2개를 선택한 뒤 열면 자동으로 채워집니다).' };
const path = findProfilePath(model, from, to);
if (!path) return { error: '두 절점을 잇는 경로가 없습니다.' };
const d = profileInput(model, result, path, { elevationIs, depth: Number(depth) || 0, series, timeIndex: p.timeIndex }, p.units, p.flowUnit);
const r = buildProfileDrawing({ sheet, info: fullInfo, ...d, minPressure: result ? p.minPressure : undefined, font, legendStyle: legend.style, legendBand: legend.band });
return { drawings: [r.drawing], pages: [effectiveTitle], note: `경로 절점 ${path.nodeIds.length}개 · 연장 ${d.stations[d.stations.length - 1].x.toFixed(1)} m · V=1:${r.vScale.toLocaleString()} H=1:${r.hScale.toLocaleString()}${result ? ` · ${series.map((s) => SERIES_LABEL[s]).join('/')}${series.includes('now') ? ` (${p.timeLabel})` : ''}` : ' · 해석 결과 없음(동수두 생략)'}` };
}
const ids = onlySelection && (p.selection.nodes.length || p.selection.links.length)
? (() => {
const links = new Set(p.selection.links);
const nodes = new Set(p.selection.nodes);
for (const id of links) { const l = model.pipes[id] ?? model.pumps[id] ?? model.valves?.[id]; if (l) { nodes.add(l.node1); nodes.add(l.node2); } }
return { nodes, links };
})()
: undefined;
const d = planInput(model, result, { mode, timeIndex: p.timeIndex, minPressure: p.minPressure, maxPressure: Number(maxPressure) || 70, maxVelocity: p.maxVelocity, ids, palette: legend.colors }, p.units);
const metric = p.crs !== 'LOCAL';
const background: Array<Array<[number, number]>> = [];
if (withBackground && metric) {
const ext = { minX: 0, minY: 0, maxX: 1, maxY: 1 };
let budget = 200_000;
for (const layer of p.userLayers.filter((l) => l.visible)) {
for (const f of layer.featureCollection.features) {
const g = f.geometry;
const lines = !g ? [] : g.type === 'LineString' ? [g.coordinates] : g.type === 'MultiLineString' || g.type === 'Polygon' ? g.coordinates : g.type === 'MultiPolygon' ? g.coordinates.flat() : [];
for (const line of lines) {
if ((budget -= line.length) < 0) break;
background.push(line.map(([lon, lat]) => lngLatToModel(lon, lat, p.crs, ext) as [number, number]));
}
}
}
}
const base = { sheet, info: fullInfo, metric, ...d, background, font, legendStyle: legend.style, legendBand: legend.band };
if (layout === 'single' || !metric) {
const r = buildNetworkDrawing({ ...base, pipeLabels, nodeLabels, pressureTags });
return { drawings: [r.drawing], pages: [effectiveTitle], note: `관로 ${d.links.length}개 · 절점 ${d.nodes.length}개 · ${metric ? `S=1:${r.scale.toLocaleString()}` : '로컬 좌표: 축척 없음 (분할 출력은 좌표계가 있어야 합니다)'}${background.length ? ` · 배경 선 ${background.length}개` : ''}${pressureTags !== 'none' && r.tagsSkipped ? ` · 수압표 ${r.tagsSkipped}개는 자리가 없어 생략` : ''}` };
}
// 색인도 + 부분도: automatic sheet layout at one detail scale.
const lay = planLayout(base, { scale: planScale === 'auto' ? 'auto' : Number(planScale), maxSheets: 40, naming: sheetNaming });
const N = lay.tiles.length;
const pad = (n: number) => String(n).padStart(2, '0');
const nameOf = (n: number) => (n === 0 ? pad(0) : sheetNaming === 'grid' ? lay.tiles[n - 1].name : pad(n));
const numberOf = (n: number) => (info.number ? `${info.number}-${nameOf(n)}` : nameOf(n));
const index = buildNetworkDrawing({ ...base, info: { ...fullInfo, title: `${effectiveTitle} 색인도`, number: numberOf(0) }, pipeLabels: 'D', nodeLabels: 'sources', pressureTags: 'none', indexTiles: lay.tiles });
const outline = d.links.map((l) => l.pts);
let skipped = 0;
const details = lay.tiles.map((t) => {
const r = buildNetworkDrawing({
...base, info: { ...fullInfo, number: numberOf(t.no) }, view: t, scale: lay.scale, sheetLabel: sheetNaming === 'grid' ? `(${t.name})` : `(${t.no}/${N})`,
pipeLabels, nodeLabels, pressureTags, keyMap: { lines: outline, tiles: lay.tiles, current: t.no }, neighbours: tileNeighbours(lay.tiles, t),
});
skipped += r.tagsSkipped;
return r.drawing;
});
return {
drawings: [index.drawing, ...details],
pages: ['색인도', ...lay.tiles.map((t) => (sheetNaming === 'grid' ? `${t.name} (${t.no}/${N})` : `${t.no}/${N}`))],
note: `색인도 1장 + 부분도 ${N}장 · 부분도 S=1:${lay.scale.toLocaleString()} · 색인도 S=1:${index.scale.toLocaleString()} · 관로 ${d.links.length}개 · 절점 ${d.nodes.length}개${pressureTags !== 'none' && skipped ? ` · 수압표 ${skipped}개는 자리가 없어 생략 (축척을 키우면 표시)` : ''}`,
};
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
}
}, [kind, sheet, info, effectiveTitle, from, to, elevationIs, depth, series, mode, pipeLabels, nodeLabels, maxPressure, onlySelection, withBackground, model, result, p.timeIndex, p.units, p.flowUnit, p.crs, p.userLayers, p.selection, p.minPressure, p.maxVelocity, p.timeLabel, layout, planScale, sheetNaming, pressureTags, font, legend]);
const pageCount = 'error' in built ? 0 : built.drawings.length;
const cur = Math.min(page, Math.max(0, pageCount - 1));
const current = 'error' in built ? null : built.drawings[cur];
const fileBase = `${effectiveTitle}${info.number ? `_${info.number}` : ''}`.replace(/[\\/:*?"<>|]/g, '_');
const printPdf = () => {
if ('error' in built) return;
const s = SHEETS[sheet];
const w = window.open('', '_blank');
if (!w) { setMsg('팝업이 막혀 인쇄 창을 열 수 없습니다. 이 사이트의 팝업을 허용하세요.'); return; }
w.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>${fileBase}</title><style>@page{size:${s.w}mm ${s.h}mm;margin:0}html,body{margin:0;padding:0}svg{display:block;break-after:page}svg:last-child{break-after:auto}</style></head><body>${built.drawings.map((dr) => toSvg(dr, { forPrint: true })).join('')}</body></html>`);
w.document.close();
w.onload = () => { w.focus(); w.print(); };
setTimeout(() => { try { w.focus(); w.print(); } catch { /* closed */ } }, 600);
setMsg(`인쇄 창에서 대상 "PDF로 저장"을 고르세요. ${built.drawings.length > 1 ? `${built.drawings.length}쪽이 한 PDF로 저장되고, ` : ''}용지는 자동으로 맞춰집니다. HY울릉도M은 이 PC에 설치되어 있어야 하며, 없으면 맑은 고딕으로 나옵니다.`);
};
const saveDxf = async () => {
if ('error' in built) return;
setBusy(true);
// Several sheets go side by side in one model space (one DXF).
const one = mergeDrawings(built.drawings);
const dxf = toDxf(one);
try {
const res = await fetch('/api/drawing/dxf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ drawing: one, name: fileBase }) });
if (!res.ok) throw new Error(String(res.status));
download(await res.blob(), `${fileBase}.dxf`);
setMsg(`DXF(AutoCAD 2018 형식)를 저장했습니다.${built.drawings.length > 1 ? ` ${built.drawings.length}장이 모형 공간에 옆으로 나란히 들어 있습니다.` : ''} 단위 mm, 도면층·선 굵기·색 구분, 글꼴 ${DRAWING_FONTS[font]?.label ?? '맑은 고딕'} (없는 PC에서는 CAD가 대체 글꼴로 표시).`);
} catch {
download(new Blob([dxf], { type: 'application/dxf' }), `${fileBase}.dxf`);
setMsg('서버에 연결할 수 없어 간이 DXF(R12, 한글은 유니코드 표기)로 저장했습니다. AutoCAD에서는 한글이 정상 표시됩니다.');
} finally {
setBusy(false);
}
};
return (
<div role="dialog" aria-modal="true" aria-label="도면 출력" style={{ position: 'fixed', inset: 0, background: 'rgba(15,23,42,.4)', zIndex: 3000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={p.onClose}>
<div onClick={(e) => e.stopPropagation()} style={{ width: 'min(1280px, 97vw)', height: '92vh', background: 'var(--bg-panel)', borderRadius: 8, boxShadow: '0 12px 40px rgba(0,0,0,.3)', display: 'flex', flexDirection: 'column', position: 'relative' }}>
<button className="btn btn-ghost" onClick={p.onClose} aria-label="닫기" style={{ position: 'absolute', top: 6, right: 8, padding: 4 }}><X size={18} /></button>
<div style={{ padding: '10px 44px 10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8, alignItems: 'center' }}>
<b style={{ marginRight: 8 }}>도면 출력</b>
{(['profile', 'plan'] as DrawingKind[]).map((k) => (
<button key={k} className={kind === k ? 'btn btn-primary' : 'btn btn-secondary'} style={{ height: 26, fontSize: 12 }} onClick={() => setKind(k)}>{k === 'profile' ? '수리 종단면도' : '관망도'}</button>
))}
</div>
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '300px minmax(0, 1fr)', minHeight: 0 }}>
<div style={{ padding: 12, borderRight: '1px solid var(--border)', overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 10, fontSize: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label style={lab}>용지<select value={sheet} onChange={(e) => setSheet(e.target.value as keyof typeof SHEETS)} style={inp}>{Object.entries(SHEETS).map(([k, s]) => <option key={k} value={k}>{s.label}</option>)}</select></label>
<label style={lab}>글꼴<select value={font} onChange={(e) => setFont(e.target.value)} style={inp}>{Object.entries(DRAWING_FONTS).map(([k, f]) => <option key={k} value={k}>{f.label}</option>)}</select></label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label style={lab}>범례 양식
<select value={legend.style} onChange={(e) => setLegend({ ...legend, style: e.target.value as LegendStyle })} style={inp}>
<option value="table">표형 (서울시 배관망도식)</option>
<option value="list">간단형 (목록)</option>
<option value="none">표시 안 함</option>
</select>
</label>
<label style={lab}>범례 제목 띠
<span style={{ display: 'flex', gap: 4 }}>
<select value={LEGEND_BANDS.some((bd) => bd.color === legend.band) ? legend.band : 'custom'} onChange={(e) => e.target.value !== 'custom' && setLegend({ ...legend, band: e.target.value })} style={{ ...inp, flex: 1 }} disabled={legend.style === 'none'}>
{LEGEND_BANDS.map((bd) => <option key={bd.color} value={bd.color}>{bd.label}</option>)}
<option value="custom">직접 지정</option>
</select>
<input type="color" aria-label="범례 제목 띠 색" value={legend.band} onChange={(e) => setLegend({ ...legend, band: e.target.value })} disabled={legend.style === 'none'} style={{ width: 30, height: 26, padding: 0, border: '1px solid #cbd5e1', borderRadius: 4 }} />
</span>
</label>
</div>
{kind === 'plan' && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 6, alignItems: 'end' }}>
<label style={lab}>색상 구성 (유속·수압·관경 구분색)
<select value={legend.preset} onChange={(e) => { const pr = PLAN_PALETTES[e.target.value]; setLegend({ ...legend, preset: e.target.value, colors: pr ? pr.colors : legend.colors }); }} style={inp}>
{Object.entries(PLAN_PALETTES).map(([key, pr]) => <option key={key} value={key}>{pr.label}</option>)}
{legend.preset === 'custom' && <option value="custom">직접 지정</option>}
</select>
</label>
<button type="button" onClick={() => setShowColors((v) => !v)} style={{ ...inp, cursor: 'pointer', width: 'auto', padding: '4px 8px' }}>{showColors ? '색 숨기기' : '색 직접 지정'}</button>
</div>
<div style={{ display: 'flex', gap: 2, margin: '4px 0' }} aria-label="현재 색상 구성">
{PALETTE_KEYS.filter((pk) => (mode === 'diameter' || !result ? pk.group === 'diameter' : pk.group !== 'diameter')).map((pk) => <span key={pk.key} title={pk.label} style={{ flex: 1, height: 8, borderRadius: 2, background: legend.colors[pk.key] }} />)}
</div>
{showColors && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 10px', padding: 6, border: '1px solid #e2e8f0', borderRadius: 6 }}>
{PALETTE_KEYS.filter((pk) => (mode === 'diameter' || !result ? pk.group === 'diameter' : pk.group !== 'diameter')).map((pk) => (
<label key={pk.key} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
<input type="color" value={legend.colors[pk.key]} onChange={(e) => setLegend({ ...legend, preset: 'custom', colors: { ...legend.colors, [pk.key]: e.target.value } })} style={{ width: 28, height: 22, padding: 0, border: '1px solid #cbd5e1', borderRadius: 4 }} />
{pk.label}
</label>
))}
</div>
)}
</div>
)}
{kind === 'profile' ? (
<>
<datalist id="drawing-node-ids">{nodeIds.slice(0, 5000).map((id) => <option key={id} value={id} />)}</datalist>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label style={lab}>시작 절점<input list="drawing-node-ids" value={from} onChange={(e) => setFrom(e.target.value.trim())} style={inp} /></label>
<label style={lab}>끝 절점<input list="drawing-node-ids" value={to} onChange={(e) => setTo(e.target.value.trim())} style={inp} /></label>
</div>
<label style={lab}>절점 표고의 의미
<select value={elevationIs} onChange={(e) => setElevationIs(e.target.value as 'invert' | 'ground')} style={inp}>
<option value="invert">관저고 (지반고 = 표고 + 매설깊이)</option>
<option value="ground">지반고 (관저고 = 표고 − 매설깊이)</option>
</select>
</label>
<label style={lab}>매설깊이 (m, 자산 속성 없을 때)<input type="number" step="0.1" value={depth} onChange={(e) => setDepth(e.target.value)} style={inp} /></label>
<div style={lab}>
동수경사선
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', color: 'var(--text)' }}>
{(Object.keys(SERIES_LABEL) as SeriesKey[]).map((k) => (
<label key={k} style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
<input type="checkbox" disabled={!result} checked={series.includes(k)} onChange={(e) => setSeries(e.target.checked ? [...series, k] : series.filter((s) => s !== k))} />
{SERIES_LABEL[k]}{k === 'now' ? ` (${p.timeLabel})` : ''}
</label>
))}
</div>
{!result && <span>수리해석을 먼저 실행하면 동수두가 그려집니다.</span>}
</div>
</>
) : (
<>
<label style={lab}>색 구분
<select value={mode} onChange={(e) => setMode(e.target.value as PlanColorMode)} style={inp}>
<option value="diameter">관경별</option>
<option value="hydraulic" disabled={!result}>수리 판정 (유속·수압, {p.timeLabel})</option>
</select>
</label>
<label style={lab}>출력 방식
<select value={layout} onChange={(e) => { setLayout(e.target.value as 'single' | 'split'); setPage(0); }} style={inp}>
<option value="split">색인도 + 부분도 (자동 분할)</option>
<option value="single">전체 한 장</option>
</select>
</label>
{layout === 'split' && (
<label style={lab}>도면 번호
<select value={sheetNaming} onChange={(e) => { setSheetNaming(e.target.value as 'serial' | 'grid'); setPage(0); }} style={inp}>
<option value="serial">일련번호 (01, 02, …)</option>
<option value="grid">격자 (A-1, A-2, B-1 …)</option>
</select>
</label>
)}
{layout === 'split' && (
<label style={lab}>부분도 축척
<select value={planScale} onChange={(e) => { setPlanScale(e.target.value); setPage(0); }} style={inp}>
<option value="auto">자동 (절점 간격에 맞춤)</option>
{[500, 1000, 1200, 1500, 2000, 2500, 3000, 5000, 6000, 10000].map((sc) => <option key={sc} value={sc}>1:{sc.toLocaleString()}</option>)}
</select>
</label>
)}
<label style={lab}>수압표 (절점별 동수두·지반고·수압·유출량)
<select value={pressureTags} onChange={(e) => setPressureTags(e.target.value as 'none' | 'kgf' | 'm')} style={inp}>
<option value="kgf">표시 (수압 kg/cm²)</option>
<option value="m">표시 (수압 m)</option>
<option value="none">표시 안 함</option>
</select>
{pressureTags !== 'none' && !result && <span style={{ color: '#b45309', fontSize: 11 }}>해석 결과가 없어 수압표를 그릴 수 없습니다. 상단 「해석 실행」을 누르면 바로 반영됩니다.</span>}
{pressureTags !== 'none' && result && layout === 'split' && page === 0 && <span style={{ color: '#64748b', fontSize: 11 }}>수압표는 색인도가 아닌 부분도(2쪽부터)에 표시됩니다.</span>}
</label>
{mode === 'hydraulic' && <label style={lab}>과압 기준 (m)<input type="number" value={maxPressure} onChange={(e) => setMaxPressure(e.target.value)} style={inp} /></label>}
<label style={lab}>관로 표기<select value={pipeLabels} onChange={(e) => setPipeLabels(e.target.value as 'none' | 'D' | 'DL' | 'IDDL')} style={inp}><option value="DL">관경 + 연장 (D300 L=125m)</option><option value="IDDL">관번호 + 관경 + 연장 (P1-D300 L=125m)</option><option value="D">관경만</option><option value="none">없음</option></select></label>
<label style={lab}>절점 표기<select value={nodeLabels} onChange={(e) => setNodeLabels(e.target.value as 'none' | 'sources' | 'all')} style={inp}><option value="sources">배수지·탱크</option><option value="all">전체 (겹치면 생략)</option><option value="none">없음</option></select></label>
<label style={{ display: 'flex', gap: 4 }}><input type="checkbox" checked={onlySelection} disabled={!p.selection.nodes.length && !p.selection.links.length} onChange={(e) => setOnlySelection(e.target.checked)} /> 선택한 요소만 ({p.selection.nodes.length + p.selection.links.length})</label>
<label style={{ display: 'flex', gap: 4 }}><input type="checkbox" checked={withBackground} disabled={p.crs === 'LOCAL'} onChange={(e) => setWithBackground(e.target.checked)} /> 켜진 GIS 레이어를 배경으로</label>
</>
)}
<hr style={{ border: 0, borderTop: '1px solid var(--border)', margin: '2px 0' }} />
<b style={{ fontSize: 12 }}>표제란</b>
<label style={lab}>도면명<input value={title} placeholder={effectiveTitle} onChange={(e) => setTitle(e.target.value)} style={inp} /></label>
<label style={lab}>사업명<input value={info.project} onChange={(e) => setInfo({ ...info, project: e.target.value })} style={inp} /></label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<label style={lab}>발주처<input value={info.client} onChange={(e) => setInfo({ ...info, client: e.target.value })} style={inp} /></label>
<label style={lab}>설계사<input value={info.company} onChange={(e) => setInfo({ ...info, company: e.target.value })} style={inp} /></label>
<label style={lab}>도면번호<input value={info.number} onChange={(e) => setInfo({ ...info, number: e.target.value })} style={inp} /></label>
<label style={lab}>작성일<input value={info.date} onChange={(e) => setInfo({ ...info, date: e.target.value })} style={inp} /></label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
{([['design', '설계'], ['draft', '제도'], ['check', '검토'], ['approve', '승인']] as const).map(([key, label]) => (
<label key={key} style={lab}>{label}<input value={info.staff?.[key] ?? ''} onChange={(e) => setInfo({ ...info, staff: { ...info.staff, [key]: e.target.value } })} style={inp} /></label>
))}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 4 }}>
<button className="btn btn-primary" style={{ height: 28 }} disabled={!current} onClick={printPdf}><Printer size={13} />PDF (인쇄)</button>
<button className="btn btn-secondary" style={{ height: 28 }} disabled={!current || busy} onClick={() => void saveDxf()}><Download size={13} />DXF</button>
<button className="btn btn-secondary" style={{ height: 28 }} disabled={!current} title="지금 보고 있는 장을 SVG로 저장" onClick={() => current && download(new Blob([toSvg(current, { forPrint: true })], { type: 'image/svg+xml' }), `${fileBase}${pageCount > 1 ? `_${cur === 0 ? '색인도' : String(cur).padStart(2, '0')}` : ''}.svg`)}>SVG</button>
</div>
{msg && <div style={{ color: 'var(--text-secondary)', lineHeight: 1.5 }}>{msg}</div>}
</div>
<div style={{ padding: 12, overflow: 'auto', background: '#e2e8f0', display: 'flex', flexDirection: 'column', gap: 6 }}>
{'error' in built ? (
<div style={{ color: 'var(--crit)', padding: 20 }}>{built.error}</div>
) : (
<>
<div style={{ fontSize: 12, color: '#334155' }}>{built.note}</div>
{pageCount > 1 && (
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap', fontSize: 12 }}>
<button className="btn btn-secondary" style={{ height: 24, padding: '0 8px' }} disabled={cur === 0} onClick={() => setPage(cur - 1)}>◀</button>
<select aria-label="도면 장" value={cur} onChange={(e) => setPage(Number(e.target.value))} style={{ height: 24, fontSize: 12 }}>
{built.pages.map((name, i) => <option key={i} value={i}>{i === 0 ? '색인도' : `부분도 ${name}`}</option>)}
</select>
<button className="btn btn-secondary" style={{ height: 24, padding: '0 8px' }} disabled={cur >= pageCount - 1} onClick={() => setPage(cur + 1)}>▶</button>
<span style={{ color: '#475569' }}>PDF·DXF는 전체 {pageCount}장, SVG는 보고 있는 장만 저장</span>
</div>
)}
<div aria-label="도면 미리보기" style={{ background: '#fff', boxShadow: '0 2px 10px rgba(0,0,0,.15)' }} dangerouslySetInnerHTML={{ __html: current ? toSvg(current) : '' }} />
</>
)}
</div>
</div>
</div>
</div>
);
};
+9 -1
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Camera, ChevronDown, Download, FileText, Layers, Table } from 'lucide-react';
import { Camera, ChevronDown, Download, FileText, Layers, Table, PenTool } from 'lucide-react';
interface ExportMenuProps {
open: boolean;
@@ -16,6 +16,7 @@ interface ExportMenuProps {
onExportShapefile: () => void;
onShowInpText: () => void;
onOpenAttributeTable: () => void;
onOpenDrawing?: () => void;
}
/** Header "내보내기" dropdown. Result-based exports are disabled until a run exists. */
@@ -34,6 +35,7 @@ export const ExportMenu: React.FC<ExportMenuProps> = ({
onExportShapefile,
onShowInpText,
onOpenAttributeTable,
onOpenDrawing,
}) => {
const act = (fn: () => void) => () => {
fn();
@@ -100,6 +102,12 @@ export const ExportMenu: React.FC<ExportMenuProps> = ({
<Table size={14} />
<span>속성 테이블 열기</span>
</button>
{onOpenDrawing && (
<button className="dropdown-item" onClick={act(onOpenDrawing)} title="수리 종단면도·관망도를 도곽·표제란과 함께 PDF/DXF로">
<PenTool size={14} />
<span>도면 출력 (종단면도·관망도)</span>
</button>
)}
</div>
)}
</div>
+73 -14
View File
@@ -442,14 +442,51 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
setSelectedRowIds(ids);
}, [category, multiSelection, selectedTarget]);
// Shift+click selects the range from the last plain/Ctrl click (the anchor)
// in the table's current filter/sort order.
const anchorRowIdRef = useRef<string | null>(null);
const rowIdOf = (row: Record<string, unknown>) => String(row.id || row.__id);
/** Select many rows at once; map-linked rows also update the map selection. */
const selectRowIds = (ids: string[], keepExisting: boolean) => {
const byId = new Map(filteredRows.map((r) => [rowIdOf(r), r]));
const targets = ids.map((id) => byId.get(id)).filter((r): r is Record<string, unknown> => !!r).map((r) => getRowTarget(r));
const mapTargets = targets.filter((t): t is { type: 'node' | 'link'; id: string } => !!t && (t.type === 'node' || t.type === 'link'));
if (!mapTargets.length) {
setSelectedRowIds((prev) => new Set([...(keepExisting ? prev : []), ...ids]));
return;
}
const next: MultiSelectionState = keepExisting
? { nodes: new Set(multiSelection?.nodes ?? []), links: new Set(multiSelection?.links ?? []), gisFeatures: [...(multiSelection?.gisFeatures ?? [])] }
: { nodes: new Set<string>(), links: new Set<string>(), gisFeatures: [] };
if (keepExisting && selectedTarget?.type === 'node') next.nodes.add(selectedTarget.id);
if (keepExisting && selectedTarget?.type === 'link') next.links.add(selectedTarget.id);
for (const t of mapTargets) (t.type === 'node' ? next.nodes : next.links).add(t.id);
onMultiSelectionChange?.(next);
setSelectedRowIds(mapTargets[0].type === 'node' ? new Set(next.nodes) : new Set(next.links));
onSelectTarget(mapTargets[mapTargets.length - 1]);
};
const allFilteredSelected = filteredRows.length > 0 && filteredRows.every((r) => selectedRowIds.has(rowIdOf(r)));
// Table selection and map selection share the same node/link ID sets.
const handleRowClick = (row: Record<string, unknown>, e: React.MouseEvent) => {
const rowId = String(row.id || row.__id);
if (e.shiftKey && anchorRowIdRef.current) {
const ids = filteredRows.map(rowIdOf);
const a = ids.indexOf(anchorRowIdRef.current);
const b = ids.indexOf(rowId);
if (a >= 0 && b >= 0) {
selectRowIds(ids.slice(Math.min(a, b), Math.max(a, b) + 1), e.ctrlKey || e.metaKey);
return;
}
}
anchorRowIdRef.current = rowId;
const target = getRowTarget(row);
if (!target) {
// Patterns/Controls/Rules/Curves have no map representation; use plain
// local multi-select for delete/copy purposes only.
const additive = e.shiftKey || e.ctrlKey || e.metaKey;
const additive = e.ctrlKey || e.metaKey;
setSelectedRowIds((prev) => {
if (!additive) return new Set([rowId]);
const next = new Set(prev);
@@ -459,7 +496,7 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
});
return;
}
const additive = e.shiftKey || e.ctrlKey || e.metaKey;
const additive = e.ctrlKey || e.metaKey;
if (!additive || target.type === 'gis-feature') {
setSelectedRowIds(new Set([rowId]));
@@ -742,15 +779,26 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
flexDirection: 'column',
overflow: 'hidden',
fontSize: '12px',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-label="속성 테이블"
>
{/* Header: Title, Category Tabs, Search, Actions, Close */}
{/* Close: fixed to the dialog's top-right corner, whatever the toolbar wraps to. */}
<button
onClick={onClose}
className="btn btn-ghost"
style={{ position: 'absolute', top: 6, right: 8, padding: '4px', zIndex: 2 }}
title="닫기 (ESC)"
aria-label="닫기"
>
<X size={18} aria-hidden="true" />
</button>
{/* Header: Title, Category Tabs, Search, Actions */}
<div
style={{
padding: '8px 14px',
padding: '8px 44px 8px 14px',
borderBottom: '1px solid var(--border)',
background: 'var(--bg-subtle)',
display: 'flex',
@@ -864,6 +912,24 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
{selectedRowIds.size > 0 ? `${selectedRowIds.size}/` : ''}{filteredRows.length}행
</span>
<button
onClick={() => {
if (allFilteredSelected) {
setSelectedRowIds(new Set());
onMultiSelectionChange?.({ nodes: new Set<string>(), links: new Set<string>(), gisFeatures: [] });
onSelectTarget(null);
} else {
selectRowIds(filteredRows.map(rowIdOf), false);
}
}}
className="btn btn-secondary"
disabled={filteredRows.length === 0}
style={{ padding: '4px 8px', fontSize: '11px' }}
title="검색·필터로 보이는 행을 모두 선택 (Shift+클릭: 범위 선택, Ctrl+클릭: 하나씩 추가)"
>
{allFilteredSelected ? '선택 해제' : '전체 선택'}
</button>
<button
onClick={() => {
if (selectedTarget && onZoomToSelection) onZoomToSelection(selectedTarget);
@@ -911,15 +977,6 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
<span>복사 (TSV)</span>
</button>
<button
onClick={onClose}
className="btn btn-ghost"
style={{ padding: '4px' }}
title="닫기 (ESC)"
aria-label="닫기"
>
<X size={16} aria-hidden="true" />
</button>
</div>
</div>
@@ -1028,6 +1085,8 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
<tr
key={rowId}
onClick={(e) => handleRowClick(row, e)}
// Shift+click would otherwise also drag-select the cell text.
onMouseDown={(e) => { if (e.shiftKey) e.preventDefault(); }}
onContextMenu={(e) => {
const target = getRowTarget(row);
if (!target) return;
@@ -1166,7 +1225,7 @@ export const FullAttributeTableModal: React.FC<FullAttributeTableModalProps> = (
}}
>
<div>
<span>셀 더블클릭 수정 · 행 우클릭 후 선택 객체 확대 · Shift/Ctrl 클릭 다중 선택 · Ctrl+C 복사</span>
<span>셀 더블클릭 수정 · 행 우클릭 후 선택 객체 확대 · Ctrl 클릭 추가 · Shift 클릭 범위 선택 · Ctrl+C 복사</span>
</div>
{toastText && (
<div style={{ color: 'var(--accent)', fontWeight: 600, display: 'flex', alignItems: 'center', gap: '4px' }}>
+4 -4
View File
@@ -264,8 +264,8 @@ export const HydraulicTableViewer: React.FC<HydraulicTableViewerProps> = ({
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--s-5)', fontSize: 'var(--fs-sm)' }}>
{/* Sub tabs and filters */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border)', paddingBottom: 'var(--s-3)' }}>
<div style={{ display: 'flex', gap: 'var(--s-2)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', rowGap: 'var(--s-2)', columnGap: 'var(--s-4)', borderBottom: '1px solid var(--border)', paddingBottom: 'var(--s-3)' }}>
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap' }}>
<button
onClick={() => setSubTab('nodes')}
className={`btn btn-secondary ${subTab === 'nodes' ? 'btn-primary' : ''}`}
@@ -282,9 +282,9 @@ export const HydraulicTableViewer: React.FC<HydraulicTableViewerProps> = ({
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-4)', fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-4)', fontSize: 'var(--fs-xs)', color: 'var(--text-secondary)', whiteSpace: 'nowrap', marginLeft: 'auto' }}>
<span style={{ fontWeight: 'var(--fw-semi)', color: 'var(--text)' }}>{currentTimeStr} 시점</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', cursor: 'pointer' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--s-2)', cursor: 'pointer', whiteSpace: 'nowrap' }}>
<input
type="checkbox"
checked={onlyWarnings}
+5 -5
View File
@@ -137,7 +137,7 @@ export const LinkInspector: React.FC<LinkInspectorProps> = ({
관로 ID
<input value={selectedPipe.id} readOnly style={{ background: 'var(--bg-panel)' }} />
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '6px', alignItems: 'end' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr) auto', gap: '6px', alignItems: 'end' }}>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
변경할 밸브 유형
<select
@@ -171,7 +171,7 @@ export const LinkInspector: React.FC<LinkInspectorProps> = ({
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '8px',
marginBottom: '12px',
}}
@@ -284,7 +284,7 @@ export const LinkInspector: React.FC<LinkInspectorProps> = ({
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '8px',
marginBottom: '12px',
}}
@@ -335,7 +335,7 @@ export const LinkInspector: React.FC<LinkInspectorProps> = ({
)}
{selectedValve && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '12px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '8px', marginBottom: '12px' }}>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
밸브 유형
<select
@@ -384,7 +384,7 @@ export const LinkInspector: React.FC<LinkInspectorProps> = ({
</div>
)}
{!selectedPump && !selectedValve && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '12px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '8px', marginBottom: '12px' }}>
<label style={{ display: 'grid', gap: '2px', fontSize: '11px', color: 'var(--text-secondary)' }}>
미소손실계수
<input
+94 -2
View File
@@ -122,6 +122,7 @@ interface MapProps {
userLayers?: UserGisLayer[];
rasterOverlays?: import('./types').RasterOverlay[];
view3d?: import('./types').View3D;
demContours?: import('./types').DemContours | null;
layerVisibility?: LayerVisibilityState;
multiSelection?: MultiSelectionState;
boxSelectionEnabled?: boolean;
@@ -221,6 +222,7 @@ export const MapViewer: React.FC<MapProps> = ({
userLayers = [],
rasterOverlays,
view3d,
demContours,
layerVisibility,
styleSettings,
multiSelection,
@@ -735,6 +737,8 @@ export const MapViewer: React.FC<MapProps> = ({
const map = new maplibregl.Map({
container: mapContainer.current,
// No (i) attribution button; the required credit is a small static text below.
attributionControl: false,
// Hangul (and other CJK) label glyphs are drawn locally with this font
// instead of being fetched as glyph PBFs.
localIdeographFontFamily: "'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif",
@@ -901,6 +905,12 @@ export const MapViewer: React.FC<MapProps> = ({
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
// Pipe labels sit on a separate point source (link midpoints): line-placed
// Hangul text (tags) made MapLibre drop the whole 'links' bucket, pipes included.
map.addSource('link-label-points', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
});
map.addSource('flow-arrows', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
@@ -1135,13 +1145,15 @@ export const MapViewer: React.FC<MapProps> = ({
map.addLayer({
id: 'links-label-layer',
type: 'symbol',
source: 'links',
source: 'link-label-points',
layout: {
'symbol-placement': 'line',
'text-field': ['get', 'id'],
'text-font': LABEL_FONT,
'text-size': 10,
'text-offset': [0, -0.8],
'text-rotate': ['get', 'angle'],
'text-rotation-alignment': 'map',
'text-max-width': 30,
visibility: 'none',
},
paint: {
@@ -2095,6 +2107,33 @@ export const MapViewer: React.FC<MapProps> = ({
useLayoutEffect(placeHoverBox, [hovered]);
// Pipe label anchors: midpoint of each drawn line, rotated along its middle leg (kept upright).
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const apply = () => {
const src = map.getSource('link-label-points') as maplibregl.GeoJSONSource | undefined;
if (!src) return;
const features: Array<Feature<Point>> = [];
for (const f of geojson?.links.features ?? []) {
const c = f.geometry.coordinates as [number, number][];
if (c.length < 2) continue;
const k = Math.floor((c.length - 1) / 2);
const [a, b] = [c[k], c[k + 1]];
const mid: [number, number] = c.length % 2 === 1 && c.length > 2 ? c[(c.length - 1) / 2] : [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
const lat = (a[1] * Math.PI) / 180;
let angle = (-Math.atan2(b[1] - a[1], (b[0] - a[0]) * Math.cos(lat)) * 180) / Math.PI;
if (angle > 90) angle -= 180;
if (angle < -90) angle += 180;
features.push({ type: 'Feature', properties: { ...f.properties, angle }, geometry: { type: 'Point', coordinates: mid } });
}
src.setData({ type: 'FeatureCollection', features });
};
if (mapReadyRef.current) apply();
else map.once('load', apply);
return () => { map.off('load', apply); };
}, [geojson]);
// Handles for the selected link: its vertices plus midpoints of every leg.
useEffect(() => {
const map = mapRef.current;
@@ -2249,6 +2288,53 @@ export const MapViewer: React.FC<MapProps> = ({
else map.once('load', applyStyles);
}, [styleSettings, sizeKey]);
// DEM 등고선: contour vector tiles generated in a worker from the terrain tiles.
const demSourcesRef = useRef<Record<string, { url: (o: object) => string }>>({});
useEffect(() => {
const map = mapRef.current;
if (!map) return;
let cancelled = false;
const apply = async () => {
for (const id of ['dem-contour-label', 'dem-contour-line']) if (map.getLayer(id)) map.removeLayer(id);
if (map.getSource('dem-contours')) map.removeSource('dem-contours');
if (!demContours) return;
const key = `${demContours.demId}:${demContours.maxzoom}`;
let src = demSourcesRef.current[key];
if (!src) {
const mlcontour = (await import('maplibre-contour')).default;
if (cancelled) return;
const dem = new mlcontour.DemSource({
url: `${window.location.origin}/api/dem/${demContours.demId}/terrain/{z}/{x}/{y}.png`,
encoding: 'terrarium',
maxzoom: demContours.maxzoom,
worker: true,
id: `spipedem${Object.keys(demSourcesRef.current).length}`,
});
dem.setupMaplibre(maplibregl);
src = { url: (o: object) => dem.contourProtocolUrl(o as Parameters<typeof dem.contourProtocolUrl>[0]) };
demSourcesRef.current[key] = src;
}
if (cancelled || map.getSource('dem-contours')) return;
const i = demContours.interval;
map.addSource('dem-contours', {
type: 'vector',
tiles: [src.url({ thresholds: { 9: [i * 10, i * 50], 11: [i * 4, i * 20], 13: [i * 2, i * 10], 14: [i, i * 5] }, elevationKey: 'ele', levelKey: 'level', contourLayer: 'contours' })],
maxzoom: 16,
});
const before = map.getLayer('links-layer') ? 'links-layer' : undefined;
map.addLayer({ id: 'dem-contour-line', type: 'line', source: 'dem-contours', 'source-layer': 'contours', paint: { 'line-color': '#a16207', 'line-opacity': 0.55, 'line-width': ['match', ['get', 'level'], 1, 1.1, 0.45] } }, before);
map.addLayer({
id: 'dem-contour-label', type: 'symbol', source: 'dem-contours', 'source-layer': 'contours', filter: ['>', ['get', 'level'], 0],
layout: { 'symbol-placement': 'line', 'text-field': ['concat', ['number-format', ['get', 'ele'], {}], 'm'], 'text-font': LABEL_FONT, 'text-size': 10 },
paint: { 'text-color': '#92400e', 'text-halo-color': '#ffffff', 'text-halo-width': 1.2 },
}, before);
};
const run = () => { void apply(); };
if (mapReadyRef.current) run();
else map.once('load', run);
return () => { cancelled = true; map.off('load', run); };
}, [demContours]);
// 3D 보기: pitch, DEM terrain (terrarium tiles from the API) and node columns.
useEffect(() => {
const map = mapRef.current;
@@ -2501,8 +2587,14 @@ export const MapViewer: React.FC<MapProps> = ({
map.once('load', syncUserLayers);
}
}, [userLayers]);
const basemapCredit = basemap === 'osm' ? '© OpenStreetMap contributors' : basemap.startsWith('vworld') ? '© VWorld (국토교통부)' : '';
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{basemapCredit && (
<div style={{ position: 'absolute', left: 6, bottom: 2, zIndex: 2, fontSize: 9, color: 'rgba(71,85,105,.75)', pointerEvents: 'none', textShadow: '0 0 2px #fff' }}>
{basemapCredit}
</div>
)}
<div className="map-notice">
{editMode === 'select' && (
<>
+2 -2
View File
@@ -85,7 +85,7 @@ export const NodeInspector: React.FC<NodeInspectorProps> = ({
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '8px',
marginBottom: '12px',
}}
@@ -133,7 +133,7 @@ export const NodeInspector: React.FC<NodeInspectorProps> = ({
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: '8px',
marginBottom: '12px',
}}
+5 -5
View File
@@ -71,13 +71,13 @@ export const PatternEditor: React.FC<PatternEditorProps> = ({
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '12px' }}>
{/* Pattern Selector */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontWeight: 600, color: 'var(--text)' }}>패턴 선택:</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '6px 8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: '1 1 220px', minWidth: 0 }}>
<span style={{ fontWeight: 600, color: 'var(--text)', whiteSpace: 'nowrap' }}>패턴 선택:</span>
<select
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
style={{ padding: '3px 8px', border: '1px solid #cbd5e1', borderRadius: '4px', fontSize: '12px' }}
style={{ padding: '3px 8px', border: '1px solid #cbd5e1', borderRadius: '4px', fontSize: '12px', flex: 1, minWidth: 0 }}
>
{patternKeys.map((id) => (
<option key={id} value={id}>
@@ -87,7 +87,7 @@ export const PatternEditor: React.FC<PatternEditorProps> = ({
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', whiteSpace: 'nowrap', marginLeft: 'auto' }}>
<span style={{ color: 'var(--text-secondary)', fontSize: '11px' }}>
{multipliers.length}구간 (24시간)
</span>
+16 -6
View File
@@ -2,7 +2,7 @@
// ground elevation, hydraulic grade line (동수두) at the current time step with
// its min-max envelope over the whole run, and the flow in each link below.
import React, { useEffect, useMemo, useState } from 'react';
import { Pause, Play, Crosshair, ArrowLeftRight } from 'lucide-react';
import { Pause, Play, Crosshair, ArrowLeftRight, PenTool } from 'lucide-react';
import type { NetworkModel } from './inpModel';
import { findProfilePath } from './profilePath';
import type { SimulationResult } from './types';
@@ -17,6 +17,8 @@ interface ProfilePanelProps {
toDisplay: { head: (v: number) => number; length: (v: number) => number; flow: (v: number) => number; diameter?: (v: number) => number };
units: { head: string; flow: string; length: string; diameter?: string };
onFocusNode: (id: string) => void;
/** 도면 출력: open the drawing dialog for this path. */
onOpenDrawing?: (from: string, to: string) => void;
}
const W = 320;
@@ -24,7 +26,7 @@ const fmt = (v: number) => (Math.abs(v) >= 100 ? v.toFixed(0) : Math.abs(v) >= 1
const hhmm = (sec: number) => `${Math.floor(sec / 3600)}:${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}`;
export const ProfilePanel: React.FC<ProfilePanelProps> = ({
model, result, timeStepIndex, onTimeStepIndexChange, selectedNodeId, toDisplay, units, onFocusNode,
model, result, timeStepIndex, onTimeStepIndexChange, selectedNodeId, toDisplay, units, onFocusNode, onOpenDrawing,
}) => {
const [fromId, setFromId] = useState('');
const [toId, setToId] = useState('');
@@ -112,10 +114,18 @@ export const ProfilePanel: React.FC<ProfilePanelProps> = ({
{pickButton(setToId, '선택 절점')}
</div>
{fromId && toId && (
<button type="button" className="btn btn-secondary" style={{ height: 22, fontSize: 'var(--fs-2xs)', alignSelf: 'flex-start' }}
onClick={() => { setFromId(toId); setToId(fromId); }} title="시작과 끝을 바꿔 종단 방향을 뒤집습니다">
<ArrowLeftRight size={11} />역으로
</button>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" style={{ height: 22, fontSize: 'var(--fs-2xs)' }}
onClick={() => { setFromId(toId); setToId(fromId); }} title="시작과 끝을 바꿔 종단 방향을 뒤집습니다">
<ArrowLeftRight size={11} />역으로
</button>
{onOpenDrawing && path && (
<button type="button" className="btn btn-secondary" style={{ height: 22, fontSize: 'var(--fs-2xs)' }}
onClick={() => onOpenDrawing(fromId.trim(), toId.trim())} title="이 경로로 수리 종단면도(도곽·표제란)를 PDF/DXF로 출력">
<PenTool size={11} />도면 출력
</button>
)}
</div>
)}
{!fromId || !toId ? (
+1 -1
View File
@@ -60,7 +60,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({
justifyContent: 'space-between',
gap: '12px',
fontSize: '11px',
overflowX: 'auto',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
+6 -1
View File
@@ -178,6 +178,11 @@ export async function uploadDem(file: File): Promise<DemInfo> {
return jsonOrThrow<DemInfo>(await demFetch('/api/dem', { method: 'POST', body: form }));
}
/** Server-provided DEMs (e.g. the national 90 m DEM). */
export async function listBaseDems(): Promise<DemInfo[]> {
return jsonOrThrow<DemInfo[]>(await demFetch('/api/dem-base'));
}
export const demPreviewUrl = (id: string) => `/api/dem/${id}/preview.png`;
/** DEM values at [x, y] points given in `crs` (proj4 / EPSG); null outside the DEM. */
@@ -187,7 +192,7 @@ export async function sampleDem(id: string, points: [number, number][], crs: str
}
/** 유역·하천: stream lines, or catchments of pour points (WGS84 GeoJSON). */
export async function demHydro(id: string, body: { op: 'streams'; minAreaKm2: number } | { op: 'watershed'; points: [number, number][]; crs: string; snap?: number }) {
export async function demHydro(id: string, body: { op: 'streams'; minAreaKm2: number; bbox?: number[]; crs?: string } | { op: 'watershed'; points: [number, number][]; crs: string; snap?: number; bbox?: number[] }) {
const res = await demFetch(`/api/dem/${id}/hydro`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
return jsonOrThrow<import('geojson').FeatureCollection>(res);
}
+412
View File
@@ -0,0 +1,412 @@
// Vector drawing model for printable sheets (수리 종단면도, 관망도).
// Units are millimetres on paper, origin bottom-left, y up (CAD convention).
// One model, two writers: SVG (preview / print to PDF) and DXF R12 (CAD).
export type Anchor = 'start' | 'middle' | 'end';
export type VAnchor = 'bottom' | 'middle' | 'top';
export interface DLine { kind: 'line'; layer: string; pts: Array<[number, number]>; width?: number; color?: string; dash?: 'dashed' | 'dotted'; closed?: boolean }
export interface DText { kind: 'text'; layer: string; x: number; y: number; text: string; size: number; anchor?: Anchor; vAnchor?: VAnchor; rotate?: number; color?: string; bold?: boolean }
export interface DCircle { kind: 'circle'; layer: string; x: number; y: number; r: number; fill?: string; color?: string; width?: number }
export interface DFill { kind: 'fill'; layer: string; pts: Array<[number, number]>; fill: string; opacity?: number }
export type DItem = DLine | DText | DCircle | DFill;
export interface Drawing {
width: number;
height: number;
title: string;
items: DItem[];
/** Font family for SVG/PDF and the DXF text style (see DRAWING_FONTS). */
font?: string;
}
/**
* Drawing fonts. HY울릉도M is the usual Korean design-drawing face; it is a
* commercial font that must be installed on the PC (it is not bundled), so
* every entry falls back to 맑은 고딕.
*/
export const DRAWING_FONTS: Record<string, { label: string; css: string; dxfFile: string; dxfFamily: string }> = {
ulleungM: { label: 'HY울릉도M', css: "'HY울릉도M','HYwulM','Malgun Gothic','맑은 고딕',sans-serif", dxfFile: 'HY울릉도M.TTF', dxfFamily: 'HY울릉도M' },
malgun: { label: '맑은 고딕', css: "'Malgun Gothic','맑은 고딕','Apple SD Gothic Neo',sans-serif", dxfFile: 'malgun.ttf', dxfFamily: 'Malgun Gothic' },
};
export const DEFAULT_FONT = 'ulleungM';
export const SHEETS: Record<string, { w: number; h: number; label: string }> = {
A3: { w: 420, h: 297, label: 'A3 가로 (420×297)' },
A2: { w: 594, h: 420, label: 'A2 가로 (594×420)' },
A1: { w: 841, h: 594, label: 'A1 가로 (841×594)' },
};
export interface TitleInfo {
project: string; // 사업명
title: string; // 도면명
number: string; // 도면번호
client: string; // 발주처
company: string; // 회사
scale: string; // 축척 표기
date: string;
/** Sign-off names (설계·제도·검토·승인). */
staff?: { design?: string; draft?: string; check?: string; approve?: string };
}
export class DrawingBuilder {
items: DItem[] = [];
width: number;
height: number;
title: string;
constructor(width: number, height: number, title: string) {
this.width = width;
this.height = height;
this.title = title;
}
line(layer: string, pts: Array<[number, number]>, o: Partial<Omit<DLine, 'kind' | 'layer' | 'pts'>> = {}) {
if (pts.length >= 2) this.items.push({ kind: 'line', layer, pts, ...o });
}
rect(layer: string, x: number, y: number, w: number, h: number, o: Partial<Omit<DLine, 'kind' | 'layer' | 'pts'>> = {}) {
this.line(layer, [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], { ...o, closed: true });
}
text(layer: string, x: number, y: number, text: string, size: number, o: Partial<Omit<DText, 'kind' | 'layer' | 'x' | 'y' | 'text' | 'size'>> = {}) {
if (text !== '') this.items.push({ kind: 'text', layer, x, y, text, size, ...o });
}
circle(layer: string, x: number, y: number, r: number, o: Partial<Omit<DCircle, 'kind' | 'layer' | 'x' | 'y' | 'r'>> = {}) {
this.items.push({ kind: 'circle', layer, x, y, r, ...o });
}
fill(layer: string, pts: Array<[number, number]>, fill: string, opacity = 1) {
if (pts.length >= 3) this.items.push({ kind: 'fill', layer, pts, fill, opacity });
}
build(font: string = DEFAULT_FONT): Drawing {
return { width: this.width, height: this.height, title: this.title, items: this.items, font };
}
}
/**
* Sheet frame in the usual Korean design-drawing layout (cf. 태성 C-1-002):
* binding margin on the left, double border, and a full-width title strip at
* the bottom — 발주처 | 개정 이력 | 설계사 | PROJECT | TITLE | 설계·제도·검토·승인 |
* 축척·작성일·도면번호. Returns the drawable area above the strip.
*/
export function sheetFrame(b: DrawingBuilder, info: TitleInfo): { x0: number; y0: number; x1: number; y1: number } {
const W = b.width, H = b.height;
const k = W / 420;
const L = 25, M = 10;
b.rect('FRAME', 0, 0, W, H, { width: 0.1, color: '#cbd5e1' });
b.rect('FRAME', L, M, W - L - M, H - 2 * M, { width: 0.7 });
const th = 17 * k;
const x0 = L, x1 = W - M, y0 = M, y1 = M + th;
b.line('TITLE', [[x0, y1], [x1, y1]], { width: 0.5 });
// Column widths as fractions of the strip (sum 1).
const fr = [0.155, 0.15, 0.135, 0.17, 0.17, 0.11, 0.11];
const xs = [x0];
for (const f of fr) xs.push(xs[xs.length - 1] + f * (x1 - x0));
for (let i = 1; i < xs.length - 1; i++) b.line('TITLE', [[xs[i], y0], [xs[i], y1]], { width: 0.35 });
const small = 1.35 * k, cap = (x: number, y: number, t: string) => b.text('TITLE', x, y, t, small, { color: '#334155' });
const mid = (a: number, c: number) => (a + c) / 2;
// 1. 발주처 (large)
b.text('TITLE', mid(xs[0], xs[1]), mid(y0, y1), info.client || '발 주 처', 3.6 * k, { anchor: 'middle', vAnchor: 'middle', bold: true, color: info.client ? '#111111' : '#94a3b8' });
// 2. Revision table: NO | DATE | REVISED | CHECKED | APPROVED, header + 3 rows.
{
const rw = [0.12, 0.2, 0.28, 0.2, 0.2];
const rx = [xs[1]];
for (const f of rw) rx.push(rx[rx.length - 1] + f * (xs[2] - xs[1]));
const rh = th / 4;
for (let r = 1; r < 4; r++) b.line('TITLE', [[xs[1], y1 - r * rh], [xs[2], y1 - r * rh]], { width: 0.13 });
for (let c = 1; c < rx.length - 1; c++) b.line('TITLE', [[rx[c], y0], [rx[c], y1]], { width: 0.13 });
['NO.', 'DATE', 'REVISED', 'CHECKED', 'APPROVED'].forEach((t, c) => b.text('TITLE', mid(rx[c], rx[c + 1]), y1 - rh / 2, t, 1.1 * k, { anchor: 'middle', vAnchor: 'middle', bold: true }));
for (let r = 1; r < 4; r++) b.text('TITLE', mid(rx[0], rx[1]), y1 - (r + 0.5) * rh, '△', 1.2 * k, { anchor: 'middle', vAnchor: 'middle' });
}
// 3. 설계사
b.text('TITLE', mid(xs[2], xs[3]), mid(y0, y1), info.company || '설 계 사', 2.9 * k, { anchor: 'middle', vAnchor: 'middle', bold: true, color: info.company ? '#111111' : '#94a3b8' });
// 4. PROJECT / 5. TITLE
cap(xs[3] + 1.2 * k, y1 - 2.6 * k, 'PROJECT');
b.text('TITLE', mid(xs[3], xs[4]), mid(y0, y1) - 1.2 * k, info.project, 2.7 * k, { anchor: 'middle', vAnchor: 'middle', bold: true });
cap(xs[4] + 1.2 * k, y1 - 2.6 * k, 'TITLE');
b.text('TITLE', mid(xs[4], xs[5]), mid(y0, y1) - 1.2 * k, info.title, 2.9 * k, { anchor: 'middle', vAnchor: 'middle', bold: true });
// 6. Sign-off: role | name, 4 rows.
const roles: Array<[string, string]> = [['설 계', info.staff?.design ?? ''], ['제 도', info.staff?.draft ?? ''], ['검 토', info.staff?.check ?? ''], ['승 인', info.staff?.approve ?? '']];
const rh = th / 4, roleW = (xs[6] - xs[5]) * 0.4;
b.line('TITLE', [[xs[5] + roleW, y0], [xs[5] + roleW, y1]], { width: 0.13 });
roles.forEach(([role, name], i) => {
const yb = y1 - (i + 1) * rh;
if (i < 3) b.line('TITLE', [[xs[5], yb], [xs[6], yb]], { width: 0.13 });
b.text('TITLE', xs[5] + roleW / 2, yb + rh / 2, role, 1.5 * k, { anchor: 'middle', vAnchor: 'middle' });
b.text('TITLE', mid(xs[5] + roleW, xs[6]), yb + rh / 2, name, 1.7 * k, { anchor: 'middle', vAnchor: 'middle' });
});
// 7. 축척 / 작성일 / 도면번호
const cells: Array<[string, string]> = [['축 척', info.scale], ['작 성 일', info.date], ['도면번호', info.number]];
const ch = th / 3, keyW = (xs[7] - xs[6]) * 0.38;
b.line('TITLE', [[xs[6] + keyW, y0], [xs[6] + keyW, y1]], { width: 0.13 });
cells.forEach(([key, v], i) => {
const yb = y1 - (i + 1) * ch;
if (i < 2) b.line('TITLE', [[xs[6], yb], [xs[7], yb]], { width: 0.13 });
b.text('TITLE', xs[6] + keyW / 2, yb + ch / 2, key, 1.4 * k, { anchor: 'middle', vAnchor: 'middle' });
b.text('TITLE', mid(xs[6] + keyW, xs[7]), yb + ch / 2, v, (i === 2 ? 2.2 : 1.6) * k, { anchor: 'middle', vAnchor: 'middle', bold: i === 2 });
});
return { x0: L + 5, y0: y1 + 5, x1: W - M - 5, y1: H - M - 5 };
}
/**
* Drawing heading: large centred name over a double rule with the scale
* right-aligned below it. Returns the y just under the heading.
*/
export function sheetHeading(b: DrawingBuilder, area: { x0: number; x1: number; y1: number }, title: string, scale: string): number {
const k = b.width / 420;
const cx = (area.x0 + area.x1) / 2;
const size = 6 * k;
const half = Math.max(45 * k, title.length * size * 0.5);
const yT = area.y1 - 6 * k;
b.text('HEADING', cx, yT, title, size, { anchor: 'middle', vAnchor: 'middle', bold: true });
const yR = yT - size * 0.75;
b.line('HEADING', [[cx - half, yR], [cx + half, yR]], { width: 0.5 });
b.line('HEADING', [[cx - half, yR - 0.9 * k], [cx + half, yR - 0.9 * k]], { width: 0.18 });
if (scale) b.text('HEADING', cx + half, yR - 2 * k, scale, 2.6 * k, { anchor: 'end', vAnchor: 'top', bold: true });
return yR - 6 * k;
}
/**
* Boxed panel with a header tab (범 례, 사업의 개요 …). Returns the inner box
* (x0, y top of the body, width) so the caller can fill rows below `top`.
*/
export function panelHeader(b: DrawingBuilder, layer: string, x: number, top: number, w: number, bodyH: number, title: string): { x: number; top: number; w: number } {
// Plain cartographic panel: one hairline frame, centred title over a thin rule
// (no tab, no tinted header), white backing so map lines do not show through.
const k = b.width / 420;
const hh = 5.2 * k;
b.fill(layer, [[x, top - bodyH], [x + w, top - bodyH], [x + w, top + hh], [x, top + hh]], '#ffffff', 0.94);
b.rect(layer, x, top - bodyH, w, bodyH + hh, { width: 0.3, color: '#1f2937' });
b.line(layer, [[x + 2 * k, top], [x + w - 2 * k, top]], { width: 0.15, color: '#475569' });
b.text(layer, x + w / 2, top + hh / 2, title.replace(/\s+/g, ' ').split('').join(' ').replace(/ {3}/g, ' '), 2.3 * k, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#111827' });
return { x, top, w };
}
export interface LegendRow { label: string; /** Draw the symbol in a 10 mm (x k) slot starting at x, centred on y. */ draw: (x: number, y: number) => void }
export interface LegendGroup { name: string; rows: LegendRow[] }
/** Legend look: ruled table (서울시 배관망도식), plain list with group headings, or none. */
export type LegendStyle = 'table' | 'list' | 'none';
function roundedBox(x0: number, y0: number, x1: number, y1: number, r: number, corners: 'all' | 'top' = 'all'): Array<[number, number]> {
const pts: Array<[number, number]> = [];
const arc = (cx: number, cy: number, a0: number) => { for (let i = 0; i <= 6; i++) { const a = ((a0 + i * 15) * Math.PI) / 180; pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]); } };
if (corners === 'all') { arc(x0 + r, y0 + r, 180); arc(x1 - r, y0 + r, 270); } else { pts.push([x0, y0], [x1, y0]); }
arc(x1 - r, y1 - r, 0); arc(x0 + r, y1 - r, 90);
return pts;
}
/**
* Legend as practice drawings draw it (서울시 수도정비기본계획 배관망도 범례):
* rounded heavy frame, grey title band "범 례", an optional sample panel
* showing how labels read, then a ruled table: group name written down a
* merged left cell, symbol column, description column.
*/
export function legendTable(
b: DrawingBuilder, layer: string, groups: LegendGroup[],
opts: { w: number; sample?: { h: number; draw: (x0: number, y0: number, w: number, h: number) => void }; rowH?: number; title?: string; style?: LegendStyle; band?: string },
): { h: number; draw: (x: number, y0: number) => void } {
const k = b.width / 420;
const titleH = 6.5 * k, rowH = opts.rowH ?? 4.4 * k, sampleH = opts.sample?.h ?? 0;
const gs = groups.filter((g) => g.rows.length);
const nRows = gs.reduce((t, g) => t + g.rows.length, 0);
if (opts.style === 'none') return { h: 0, draw: () => undefined };
if (opts.style === 'list') return legendList(b, layer, gs, { ...opts, rowH: rowH * 0.9 });
const band = opts.band ?? '#c8c8c8';
const h = titleH + sampleH + nRows * rowH;
const w = opts.w, gw = 8 * k, sw = 15 * k, r = 2 * k;
return {
h,
draw: (x, y0) => {
const top = y0 + h;
b.fill(layer, roundedBox(x, y0, x + w, top, r), '#ffffff', 0.95);
if (band.toLowerCase() !== '#ffffff') b.fill(layer, roundedBox(x, top - titleH, x + w, top, r, 'top'), band);
// Title letters spread out (SVG collapses runs of spaces).
const tc = (opts.title ?? '범례').replace(/\s+/g, '').split('');
const span = tc.length > 1 ? Math.min(w * 0.36, tc.length * 6 * k) : 0;
tc.forEach((c, i) => b.text(layer, x + w / 2 - span / 2 + (tc.length > 1 ? (i * span) / (tc.length - 1) : 0), top - titleH / 2, c, 3 * k, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#111111' }));
b.line(layer, [[x, top - titleH], [x + w, top - titleH]], { width: 0.3, color: '#111111' });
if (opts.sample) {
opts.sample.draw(x, top - titleH - sampleH, w, sampleH);
b.line(layer, [[x, top - titleH - sampleH], [x + w, top - titleH - sampleH]], { width: 0.3, color: '#111111' });
}
let y = top - titleH - sampleH;
gs.forEach((g, gi) => {
const gh = g.rows.length * rowH;
if (gi) b.line(layer, [[x, y], [x + w, y]], { width: 0.3, color: '#111111' });
b.line(layer, [[x + gw, y], [x + gw, y - gh]], { width: 0.2, color: '#111111' });
b.line(layer, [[x + gw + sw, y], [x + gw + sw, y - gh]], { width: 0.2, color: '#111111' });
const chars = g.name.replace(/\s+/g, '').split('');
const step = Math.min(gh / (chars.length + 0.3), 4.5 * k);
const fs = Math.min(2.6 * k, step * 0.8);
chars.forEach((c, i) => b.text(layer, x + gw / 2, y - gh / 2 + ((chars.length - 1) / 2 - i) * step, c, fs, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#111111' }));
g.rows.forEach((row, i) => {
const cy = y - (i + 0.5) * rowH;
if (i) b.line(layer, [[x + gw, y - i * rowH], [x + w, y - i * rowH]], { width: 0.15, color: '#111111' });
row.draw(x + gw + (sw - 10 * k) / 2, cy);
b.text(layer, x + gw + sw + 2.2 * k, cy, row.label, 1.9 * k, { vAnchor: 'middle', color: '#111111' });
});
y -= gh;
});
b.line(layer, roundedBox(x, y0, x + w, top, r), { width: 0.5, color: '#111111', closed: true });
},
};
}
/** Plain list legend: hairline frame, centred title over a thin rule, small grey group headings. */
function legendList(
b: DrawingBuilder, layer: string, gs: LegendGroup[],
opts: { w: number; sample?: { h: number; draw: (x0: number, y0: number, w: number, h: number) => void }; rowH: number; title?: string; band?: string },
): { h: number; draw: (x: number, y0: number) => void } {
const k = b.width / 420;
const titleH = 5.5 * k, headH = 3.6 * k, rowH = opts.rowH, sampleH = opts.sample?.h ?? 0, w = opts.w;
const h = titleH + sampleH + gs.reduce((t, g) => t + headH + g.rows.length * rowH, 0) + 1.5 * k;
return {
h,
draw: (x, y0) => {
const top = y0 + h;
b.fill(layer, [[x, y0], [x + w, y0], [x + w, top], [x, top]], '#ffffff', 0.94);
const band = opts.band ?? '#ffffff';
if (band.toLowerCase() !== '#ffffff') b.fill(layer, [[x, top - titleH], [x + w, top - titleH], [x + w, top], [x, top]], band);
b.rect(layer, x, y0, w, h, { width: 0.3, color: '#1f2937' });
b.line(layer, [[x + 2 * k, top - titleH], [x + w - 2 * k, top - titleH]], { width: 0.15, color: '#475569' });
const tc = (opts.title ?? '범례').replace(/\s+/g, '').split('');
const span = tc.length > 1 ? Math.min(w * 0.25, tc.length * 4 * k) : 0;
tc.forEach((c, i) => b.text(layer, x + w / 2 - span / 2 + (tc.length > 1 ? (i * span) / (tc.length - 1) : 0), top - titleH / 2, c, 2.4 * k, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#111827' }));
let y = top - titleH;
if (opts.sample) { opts.sample.draw(x, y - sampleH, w, sampleH); y -= sampleH; b.line(layer, [[x + 2 * k, y], [x + w - 2 * k, y]], { width: 0.1, color: '#cbd5e1' }); }
gs.forEach((g, gi) => {
if (gi) b.line(layer, [[x + 3 * k, y], [x + w - 3 * k, y]], { width: 0.1, color: '#cbd5e1' });
b.text(layer, x + 3 * k, y - headH / 2, g.name, 1.7 * k, { vAnchor: 'middle', bold: true, color: '#475569' });
y -= headH;
for (const row of g.rows) {
const cy = y - rowH / 2;
row.draw(x + 4 * k, cy);
b.text(layer, x + 17 * k, cy, row.label, 1.8 * k, { vAnchor: 'middle', color: '#1f2937' });
y -= rowH;
}
});
},
};
}
/** Sheets side by side (30 mm apart) in one drawing, for a single DXF. */
export function mergeDrawings(ds: Drawing[], gap = 30): Drawing {
if (ds.length === 1) return ds[0];
const items: DItem[] = [];
let x = 0;
for (const d of ds) {
for (const it of d.items) {
if (it.kind === 'line' || it.kind === 'fill') items.push({ ...it, pts: it.pts.map(([px, py]) => [px + x, py] as [number, number]) });
else items.push({ ...it, x: it.x + x });
}
x += d.width + gap;
}
return { width: x - gap, height: Math.max(...ds.map((d) => d.height)), title: ds[0].title, items, font: ds[0].font };
}
/** Smallest "nice" drawing scale (1:s) at which `extentMm` real metres fit in `paperMm`. */
export function niceScale(extentM: number, paperMm: number, candidates = [50, 100, 200, 250, 300, 400, 500, 600, 700, 800, 1000, 1200, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7000, 8000, 10000, 12000, 15000, 20000, 25000, 30000, 40000, 50000, 60000, 75000, 80000, 100000]): number {
const need = (extentM * 1000) / Math.max(paperMm, 1);
return candidates.find((s) => s >= need) ?? Math.ceil(need / 10000) * 10000;
}
// ---------------------------------------------------------------- SVG
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const f = (n: number) => (Math.round(n * 100) / 100).toString();
export function toSvg(d: Drawing, opts: { forPrint?: boolean } = {}): string {
const H = d.height;
const parts: string[] = [];
const byLayer = new Map<string, string[]>();
const push = (layer: string, s: string) => { const a = byLayer.get(layer) ?? []; a.push(s); byLayer.set(layer, a); };
for (const it of d.items) {
if (it.kind === 'line') {
const pts = it.pts.map(([x, y]) => `${f(x)},${f(H - y)}`).join(' ');
const dash = it.dash === 'dashed' ? ` stroke-dasharray="${f((it.width ?? 0.25) * 8)} ${f((it.width ?? 0.25) * 5)}"` : it.dash === 'dotted' ? ` stroke-dasharray="${f((it.width ?? 0.25) * 1.5)} ${f((it.width ?? 0.25) * 3)}"` : '';
push(it.layer, `<${it.closed ? 'polygon' : 'polyline'} points="${pts}" fill="none" stroke="${it.color ?? '#111'}" stroke-width="${f(it.width ?? 0.25)}"${dash} stroke-linejoin="round" stroke-linecap="round"/>`);
} else if (it.kind === 'fill') {
push(it.layer, `<polygon points="${it.pts.map(([x, y]) => `${f(x)},${f(H - y)}`).join(' ')}" fill="${it.fill}" fill-opacity="${it.opacity ?? 1}" stroke="none"/>`);
} else if (it.kind === 'circle') {
push(it.layer, `<circle cx="${f(it.x)}" cy="${f(H - it.y)}" r="${f(it.r)}" fill="${it.fill ?? 'none'}" stroke="${it.color ?? '#111'}" stroke-width="${f(it.width ?? 0.2)}"/>`);
} else {
const anchor = it.anchor ?? 'start';
const base = it.vAnchor === 'middle' ? 'central' : it.vAnchor === 'top' ? 'hanging' : 'alphabetic';
const rot = it.rotate ? ` transform="rotate(${f(-it.rotate)} ${f(it.x)} ${f(H - it.y)})"` : '';
push(it.layer, `<text x="${f(it.x)}" y="${f(H - it.y)}" font-size="${f(it.size)}" text-anchor="${anchor}" dominant-baseline="${base}" fill="${it.color ?? '#111'}"${it.bold ? ' font-weight="700"' : ''}${rot}>${esc(it.text)}</text>`);
}
}
for (const [layer, list] of byLayer) parts.push(`<g data-layer="${esc(layer)}">${list.join('')}</g>`);
const size = opts.forPrint ? `width="${d.width}mm" height="${d.height}mm"` : `width="100%"`;
return `<svg xmlns="http://www.w3.org/2000/svg" ${size} viewBox="0 0 ${d.width} ${d.height}" font-family="${esc((DRAWING_FONTS[d.font ?? ''] ?? DRAWING_FONTS.malgun).css)}"><rect width="${d.width}" height="${d.height}" fill="#fff"/>${parts.join('')}</svg>`;
}
// ---------------------------------------------------------------- DXF (R12, mm)
/** Non-ASCII as \U+XXXX (how AutoCAD/ezdxf store Unicode in R12 text). */
function dxfText(s: string): string {
let out = '';
for (const ch of s) {
const c = ch.codePointAt(0)!;
out += c < 128 ? ch : c <= 0xffff ? `\\U+${c.toString(16).toUpperCase().padStart(4, '0')}` : '?';
}
return out;
}
const ACI: Array<[number, [number, number, number]]> = [
[1, [255, 0, 0]], [2, [255, 255, 0]], [3, [0, 255, 0]], [4, [0, 255, 255]], [5, [0, 0, 255]], [6, [255, 0, 255]], [7, [0, 0, 0]], [8, [128, 128, 128]], [9, [192, 192, 192]],
[30, [255, 127, 0]], [140, [0, 127, 255]], [94, [0, 128, 0]], [12, [165, 0, 0]], [150, [0, 63, 127]],
];
function aci(color?: string): number {
if (!color || !/^#[0-9a-f]{6}$/i.test(color)) return 7;
const r = parseInt(color.slice(1, 3), 16), g = parseInt(color.slice(3, 5), 16), b = parseInt(color.slice(5, 7), 16);
let best = 7, bd = Infinity;
for (const [i, [R, G, B]] of ACI) {
const dd = (R - r) ** 2 + (G - g) ** 2 + (B - b) ** 2;
if (dd < bd) { bd = dd; best = i; }
}
return best;
}
export function toDxf(d: Drawing): string {
const o: string[] = [];
const g = (code: number, v: string | number) => o.push(String(code), typeof v === 'number' ? f(v) : v);
const layers = [...new Set(d.items.map((i) => i.layer))];
g(0, 'SECTION'); g(2, 'HEADER');
g(9, '$ACADVER'); g(1, 'AC1009');
g(9, '$EXTMIN'); g(10, 0); g(20, 0);
g(9, '$EXTMAX'); g(10, d.width); g(20, d.height);
g(0, 'ENDSEC');
g(0, 'SECTION'); g(2, 'TABLES');
g(0, 'TABLE'); g(2, 'LTYPE'); g(70, 3);
for (const [name, desc, pat] of [['CONTINUOUS', 'Solid', [] as number[]], ['DASHED', '__ __', [4, -2.5]], ['DOT', '. . .', [0.5, -1.5]]] as Array<[string, string, number[]]>) {
g(0, 'LTYPE'); g(2, name); g(70, 0); g(3, desc); g(72, 65); g(73, pat.length); g(40, pat.reduce((a, b) => a + Math.abs(b), 0));
for (const p of pat) g(49, p);
}
g(0, 'ENDTAB');
g(0, 'TABLE'); g(2, 'LAYER'); g(70, layers.length);
for (const l of layers) { g(0, 'LAYER'); g(2, l); g(70, 0); g(62, 7); g(6, 'CONTINUOUS'); }
g(0, 'ENDTAB');
g(0, 'TABLE'); g(2, 'STYLE'); g(70, 1);
g(0, 'STYLE'); g(2, 'STANDARD'); g(70, 0); g(40, 0); g(41, 1); g(50, 0); g(71, 0); g(42, 2.5); g(3, 'malgun.ttf'); g(4, '');
g(0, 'ENDTAB');
g(0, 'ENDSEC');
g(0, 'SECTION'); g(2, 'ENTITIES');
for (const it of d.items) {
if (it.kind === 'line' || it.kind === 'fill') {
const pts = it.pts;
g(0, 'POLYLINE'); g(8, it.layer); g(62, aci(it.kind === 'fill' ? it.fill : it.color));
if (it.kind === 'line' && it.dash) g(6, it.dash === 'dashed' ? 'DASHED' : 'DOT');
g(66, 1); g(10, 0); g(20, 0); g(30, 0); g(70, it.kind === 'fill' || (it.kind === 'line' && it.closed) ? 1 : 0);
if (it.kind === 'line' && it.width && it.width >= 0.5) { g(40, it.width); g(41, it.width); }
for (const [x, y] of pts) { g(0, 'VERTEX'); g(8, it.layer); g(10, x); g(20, y); g(30, 0); }
g(0, 'SEQEND'); g(8, it.layer);
} else if (it.kind === 'circle') {
g(0, 'CIRCLE'); g(8, it.layer); g(62, aci(it.color)); g(10, it.x); g(20, it.y); g(30, 0); g(40, it.r);
} else {
const h = it.anchor === 'middle' ? 1 : it.anchor === 'end' ? 2 : 0;
const v = it.vAnchor === 'middle' ? 2 : it.vAnchor === 'top' ? 3 : 0;
g(0, 'TEXT'); g(8, it.layer); g(62, aci(it.color)); g(10, it.x); g(20, it.y); g(30, 0); g(40, it.size * 0.8);
g(1, dxfText(it.text)); if (it.rotate) g(50, it.rotate);
if (h || v) { g(72, h); g(73, v); g(11, it.x); g(21, it.y); g(31, 0); }
}
}
g(0, 'ENDSEC'); g(0, 'EOF');
return o.join('\r\n') + '\r\n';
}
+209
View File
@@ -0,0 +1,209 @@
// Model + result -> drawing inputs (수리 종단면도, 관망도), in display units.
import type { NetworkModel } from './inpModel';
import type { SimulationResult } from './types';
import type { ProfileFitting, ProfileRun, ProfileSeries, ProfileStation } from './profileDrawing.ts';
import type { PlanLegendItem, PlanLink, PlanNode } from './networkDrawing.ts';
export interface Conv {
head: (v: number) => number; // model elevation/head -> display (m)
length: (v: number) => number;
diameter: (v: number) => number;
flow: (v: number) => number;
pressure: (v: number) => number;
velocity: (v: number) => number;
}
export type SeriesKey = 'now' | 'min' | 'max' | 'avg';
export const SERIES_LABEL: Record<SeriesKey, string> = { now: '선택 시점', min: '최소', max: '최대', avg: '평균' };
const SERIES_STYLE: Record<SeriesKey, Omit<ProfileSeries, 'name'>> = {
now: { color: '#1d4ed8', dash: 'dashed' },
min: { color: '#0891b2', dash: 'dotted' },
max: { color: '#dc2626', dash: 'dotted' },
avg: { color: '#7c3aed', dash: 'dashed' },
};
export interface ProfileOptions {
/** What node elevation means in this model. */
elevationIs: 'invert' | 'ground';
/** Burial depth (m) when elevation is the invert and no asset depth exists. */
depth: number;
series: SeriesKey[];
timeIndex: number;
}
function seriesValue(s: number[] | undefined, fallback: number, key: SeriesKey, t: number): number {
if (!s?.length) return fallback;
if (key === 'now') return s[Math.min(t, s.length - 1)] ?? fallback;
if (key === 'min') return Math.min(...s);
if (key === 'max') return Math.max(...s);
return s.reduce((a, b) => a + b, 0) / s.length;
}
export function profileInput(
model: NetworkModel,
result: SimulationResult | null,
path: { nodeIds: string[]; linkIds: string[]; linkLengths: number[] },
o: ProfileOptions,
u: Conv,
flowUnit: string,
): { stations: ProfileStation[]; runs: ProfileRun[]; series: ProfileSeries[]; marks: Array<{ x: number; text: string }>; fittings: ProfileFitting[] } {
const series = (result ? o.series : []).map((k) => ({ name: SERIES_LABEL[k], ...SERIES_STYLE[k] }));
const stations: ProfileStation[] = [];
const runs: ProfileRun[] = [];
const marks: Array<{ x: number; text: string }> = [];
const fittings: ProfileFitting[] = [];
let x = 0;
path.nodeIds.forEach((id, i) => {
if (i > 0) x += u.length(path.linkLengths[i - 1] ?? 0);
const n = model.nodes[id];
const elev = u.head(n?.elevation ?? 0);
const depthAsset = Number(model.assets?.nodes[id]?.depth);
const depth = Number.isFinite(depthAsset) && depthAsset > 0 ? depthAsset : o.depth;
const isNode = n?.type === 'JUNCTION';
const ground = isNode && o.elevationIs === 'invert' ? elev + depth : elev;
const invert = isNode && o.elevationIs === 'invert' ? elev : isNode ? elev - depth : elev;
const nr = result?.nodes[id];
const heads = (result ? o.series : []).map((k) => u.head(seriesValue(nr?.timeSeries?.head, nr?.head ?? NaN, k, o.timeIndex)));
const p = nr ? u.pressure(seriesValue(nr.timeSeries?.pressure, nr.pressure, o.series[0] ?? 'now', o.timeIndex)) : undefined;
const kind = n?.type === 'RESERVOIR' ? 'reservoir' : n?.type === 'TANK' ? 'tank' : 'junction';
// Water levels: reservoir head = its elevation; tank H.W.L/L.W.L from levels.
const hwl = kind === 'reservoir' ? elev : kind === 'tank' ? u.head((n?.elevation ?? 0) + (n?.maxLevel ?? n?.initLevel ?? 0)) : undefined;
const lwl = kind === 'tank' ? u.head((n?.elevation ?? 0) + (n?.minLevel ?? 0)) : undefined;
stations.push({ id, x, ground, invert, heads, pressure: p, kind, hwl, lwl });
if (n && n.type !== 'JUNCTION') marks.push({ x, text: `${n.type === 'RESERVOIR' ? '배수지' : '탱크'} ${id}` });
});
path.linkIds.forEach((lid, i) => {
const pipe = model.pipes[lid];
const valve = model.valves?.[lid];
const pump = model.pumps[lid];
const at = (stations[i].x + stations[i + 1].x) / 2;
if (valve) {
const t = valve.valveType.toUpperCase();
const set = Number(valve.setting);
const setTxt = !Number.isFinite(set) ? '' : ['PRV', 'PSV', 'PBV'].includes(t) ? ` ${u.pressure(set).toFixed(1)}m` : t === 'FCV' ? ` ${u.flow(set).toFixed(1)}${flowUnit}` : '';
fittings.push({ x: at, kind: 'valve', subtype: t, label: `${VALVE_NAME[t] ?? '밸브'} ${lid} (${t}${setTxt})` });
} else if (pump) {
fittings.push({ x: at, kind: 'pump', label: `가압펌프 ${lid}` });
}
if (!pipe) return; // pumps / valves have no run
const lr = result?.links[lid];
const q = lr ? Math.abs(seriesValue(lr.timeSeries?.flow, lr.flow, o.series[0] ?? 'now', o.timeIndex)) : undefined;
const hl = lr ? Math.abs(seriesValue(lr.timeSeries?.headloss, lr.headloss, o.series[0] ?? 'now', o.timeIndex)) : undefined;
const len = u.length(pipe.length);
runs.push({
fromX: stations[i].x,
toX: stations[i + 1].x,
diameter: u.diameter(pipe.diameter),
length: len,
roughness: model.options.headloss?.toUpperCase().startsWith('H') ? pipe.roughness : undefined,
flow: q !== undefined ? u.flow(q) : undefined,
flowUnit,
gradient: hl !== undefined && len > 0 ? (u.head(hl) / len) * 1000 : undefined,
});
});
return { stations, runs, series, marks, fittings };
}
const VALVE_NAME: Record<string, string> = { PRV: '감압밸브', PSV: '지속밸브', PBV: '압력차단밸브', FCV: '유량조절밸브', TCV: '제어밸브', GPV: '범용밸브', PCV: '제어밸브' };
export type PlanColorMode = 'diameter' | 'hydraulic';
/** Class colours of a plan drawing (velocity, pressure, diameter classes). */
export interface PlanPalette {
velHigh: string; velMid: string; velOk: string; velLow: string;
pLow: string; pOk: string; pHigh: string;
d300: string; d150: string; d100: string; d0: string;
}
export const PALETTE_KEYS: Array<{ key: keyof PlanPalette; label: string; group: 'velocity' | 'pressure' | 'diameter' }> = [
{ key: 'velHigh', label: '유속 기준 초과', group: 'velocity' },
{ key: 'velMid', label: '유속 1.0m/s~기준', group: 'velocity' },
{ key: 'velOk', label: '유속 0.3~1.0m/s', group: 'velocity' },
{ key: 'velLow', label: '유속 0.3m/s 미만', group: 'velocity' },
{ key: 'pLow', label: '수압 최소 미만', group: 'pressure' },
{ key: 'pOk', label: '수압 적정', group: 'pressure' },
{ key: 'pHigh', label: '수압 최대 초과', group: 'pressure' },
{ key: 'd300', label: 'D300 이상', group: 'diameter' },
{ key: 'd150', label: 'D150~D250', group: 'diameter' },
{ key: 'd100', label: 'D100~D125', group: 'diameter' },
{ key: 'd0', label: 'D100 미만', group: 'diameter' },
];
export const PLAN_PALETTES: Record<string, { label: string; colors: PlanPalette }> = {
standard: { label: '기본 (컬러)', colors: { velHigh: '#dc2626', velMid: '#f59e0b', velOk: '#16a34a', velLow: '#94a3b8', pLow: '#dc2626', pOk: '#2563eb', pHigh: '#f97316', d300: '#1d4ed8', d150: '#16a34a', d100: '#ca8a04', d0: '#9ca3af' } },
cad: { label: 'CAD 기본색 (빨강·자홍·초록·파랑)', colors: { velHigh: '#ff0000', velMid: '#ff00ff', velOk: '#00a000', velLow: '#0000ff', pLow: '#ff0000', pOk: '#0000ff', pHigh: '#ff00ff', d300: '#0000ff', d150: '#00a000', d100: '#ff00ff', d0: '#808080' } },
colorblind: { label: '색약 배려 (Okabe-Ito)', colors: { velHigh: '#d55e00', velMid: '#e69f00', velOk: '#009e73', velLow: '#999999', pLow: '#d55e00', pOk: '#0072b2', pHigh: '#cc79a7', d300: '#0072b2', d150: '#009e73', d100: '#e69f00', d0: '#999999' } },
mono: { label: '흑백 인쇄용', colors: { velHigh: '#000000', velMid: '#3f3f3f', velOk: '#7a7a7a', velLow: '#b4b4b4', pLow: '#000000', pOk: '#8a8a8a', pHigh: '#3f3f3f', d300: '#000000', d150: '#3f3f3f', d100: '#7a7a7a', d0: '#b4b4b4' } },
};
export function planInput(
model: NetworkModel,
result: SimulationResult | null,
o: { mode: PlanColorMode; timeIndex: number; minPressure: number; maxPressure: number; maxVelocity: number; ids?: { nodes: Set<string>; links: Set<string> }; palette?: PlanPalette },
u: Conv,
): { links: PlanLink[]; nodes: PlanNode[]; legend: PlanLegendItem[]; legendTitle: string } {
const pal = o.palette ?? PLAN_PALETTES.standard.colors;
const D_CLASSES: Array<[number, string, string]> = [[300, pal.d300, 'D300 이상'], [150, pal.d150, 'D150~D250'], [100, pal.d100, 'D100~D125'], [0, pal.d0, 'D100 미만']];
const at = (s: number[] | undefined, v: number) => (s?.length ? s[Math.min(o.timeIndex, s.length - 1)] ?? v : v);
const pipeColor = (id: string, dmm: number): string => {
if (o.mode === 'diameter' || !result) return D_CLASSES.find(([min]) => dmm >= min)![1];
const lr = result.links[id];
if (!lr) return '#9ca3af';
const v = Math.abs(u.velocity(at(lr.timeSeries?.velocity, lr.velocity)));
return v > o.maxVelocity ? pal.velHigh : v >= 1 ? pal.velMid : v >= 0.3 ? pal.velOk : pal.velLow;
};
const nodeClass = (id: string): 'low' | 'ok' | 'high' | null => {
if (o.mode === 'diameter' || !result) return null;
const nr = result.nodes[id];
if (!nr) return null;
const p = u.pressure(at(nr.timeSeries?.pressure, nr.pressure));
return p < o.minPressure ? 'low' : p > o.maxPressure ? 'high' : 'ok';
};
const nodeColor = (id: string): string => {
const c = nodeClass(id);
return c === 'low' ? pal.pLow : c === 'high' ? pal.pHigh : c === 'ok' ? pal.pOk : '#334155';
};
const keepL = (id: string) => !o.ids || o.ids.links.has(id);
const keepN = (id: string) => !o.ids || o.ids.nodes.has(id);
const xy = (id: string): [number, number] => [model.nodes[id].x, model.nodes[id].y];
const links: PlanLink[] = [];
for (const p of Object.values(model.pipes)) {
if (!keepL(p.id) || !model.nodes[p.node1] || !model.nodes[p.node2]) continue;
const d = u.diameter(p.diameter);
links.push({
id: p.id, kind: 'pipe', diameter: d, length: u.length(p.length), color: pipeColor(p.id, d),
closed: (model.extras?.statusOverrides[p.id] ?? p.status ?? '').toUpperCase().startsWith('CLOSED'),
pts: [xy(p.node1), ...(model.vertices?.[p.id] ?? []).map((v) => [v.x, v.y] as [number, number]), xy(p.node2)],
});
}
for (const [kind, rec] of [['pump', model.pumps], ['valve', model.valves ?? {}]] as const) {
for (const l of Object.values(rec)) {
if (!keepL(l.id) || !model.nodes[l.node1] || !model.nodes[l.node2]) continue;
links.push({ id: l.id, kind, subtype: kind === 'valve' ? (l as { valveType?: string }).valveType?.toUpperCase() : undefined, color: '#111111', pts: [xy(l.node1), ...(model.vertices?.[l.id] ?? []).map((v) => [v.x, v.y] as [number, number]), xy(l.node2)] });
}
}
const nodes: PlanNode[] = Object.values(model.nodes).filter((n) => keepN(n.id)).map((n) => ({
id: n.id, type: n.type, x: n.x, y: n.y, color: nodeColor(n.id),
alert: nodeClass(n.id) === 'low' || nodeClass(n.id) === 'high',
elevation: u.head(n.elevation ?? 0),
...(result?.nodes[n.id] ? (() => {
const nr = result.nodes[n.id];
return {
head: u.head(at(nr.timeSeries?.head, nr.head)),
pressure: u.pressure(at(nr.timeSeries?.pressure, nr.pressure)),
demand: u.flow(at(nr.timeSeries?.demand, nr.demand)),
};
})() : {}),
label: n.type === 'RESERVOIR' ? `배수지 ${n.id}` : n.type === 'TANK' ? `탱크 ${n.id}` : undefined,
}));
const legend: PlanLegendItem[] = o.mode === 'diameter' || !result
? D_CLASSES.map(([min, color, label]) => ({ label, color, shape: 'line', diameter: Math.max(min, 80) }))
: [
{ label: `유속 ${o.maxVelocity}m/s 초과`, color: pal.velHigh, shape: 'line' },
{ label: `유속 1.0~${o.maxVelocity}m/s`, color: pal.velMid, shape: 'line' },
{ label: '유속 0.3~1.0m/s', color: pal.velOk, shape: 'line' },
{ label: '유속 0.3m/s 미만', color: pal.velLow, shape: 'line' },
{ label: `수압 ${o.minPressure}m 미만`, color: pal.pLow, shape: 'dot' },
{ label: '수압 적정', color: pal.pOk, shape: 'dot' },
{ label: `수압 ${o.maxPressure}m 초과`, color: pal.pHigh, shape: 'dot' },
];
return { links, nodes, legend, legendTitle: o.mode === 'diameter' || !result ? '범 례 (관경)' : '범 례 (수리 판정)' };
}
+14
View File
@@ -850,3 +850,17 @@ tr:hover td {
background: var(--border);
margin: 2px 0;
}
/* Right dock (속성·결과·검증·분석): fields shrink to their grid/flex cell
instead of pushing the content wider than the dock (inputs and selects
otherwise keep an intrinsic ~150px minimum width). */
.panel input:not([type='checkbox']):not([type='radio']):not([type='range']),
.panel select,
.panel textarea {
min-width: 0;
max-width: 100%;
}
.panel label,
.panel .inspector-pane > * {
min-width: 0;
}
+668
View File
@@ -0,0 +1,668 @@
// 관망도 (network plan sheet): pipes and nodes at a nice scale with D/L labels,
// coordinate grid, legend, north arrow, scale bar and the title strip (cf. 태성
// C-1-002). Coordinates come in model units (metres for a projected CRS);
// background GIS lines are drawn in grey.
import { DrawingBuilder, legendTable, niceScale, panelHeader, sheetFrame, sheetHeading, SHEETS, type Drawing, type LegendStyle, type TitleInfo } from './drawing.ts';
export interface PlanLink {
id: string;
kind: 'pipe' | 'pump' | 'valve';
/** Valve type (PRV, FCV …). */
subtype?: string;
pts: Array<[number, number]>;
diameter?: number; // mm
length?: number; // m
color: string;
closed?: boolean;
}
export interface PlanNode {
id: string;
type: 'JUNCTION' | 'RESERVOIR' | 'TANK';
x: number;
y: number;
color: string;
/** Out of the design range: drawn larger with a ring. */
alert?: boolean;
label?: string;
/** For pressure tags (display units). */
head?: number;
elevation?: number;
pressure?: number;
demand?: number;
}
/** One detail sheet: the model window it shows and its cell in the sheet grid (i = column from west, j = row from south). */
export interface PlanTile { no: number; minX: number; minY: number; maxX: number; maxY: number; i: number; j: number; name: string }
/** Neighbouring sheet: number/name and the grid direction (dx east, dy north, each -1..1). */
export interface PlanNeighbour { no: number; name: string; dx: number; dy: number }
export interface PlanLegendItem {
label: string;
color: string;
shape: 'line' | 'dot';
/** Representative diameter (mm) for line width. */
diameter?: number;
}
export interface PlanSheetInput {
sheet: keyof typeof SHEETS;
info: Omit<TitleInfo, 'scale'>;
/** False for LOCAL (unitless) coordinates: no scale bar, "NONE" scale. */
metric: boolean;
links: PlanLink[];
nodes: PlanNode[];
legend: PlanLegendItem[];
legendTitle: string;
background?: Array<Array<[number, number]>>;
/** Pipe labels: none, D only, D+L, pipe ID + D + L. */
pipeLabels: 'none' | 'D' | 'DL' | 'IDDL';
/** Minimum pipe length on paper (mm) to get a label. */
labelMinMm?: number;
nodeLabels: 'none' | 'sources' | 'all';
/** Detail sheet: model window to show at a fixed scale. */
view?: { minX: number; minY: number; maxX: number; maxY: number };
scale?: number;
/** "(3/12)" after the drawing name. */
sheetLabel?: string;
/** Location inset (위치도) for detail sheets. */
keyMap?: { lines: Array<Array<[number, number]>>; tiles: PlanTile[]; current: number };
/** Adjacent sheets, shown at the frame edge. */
neighbours?: PlanNeighbour[];
/** Index sheet (색인도): tile rectangles with numbers. */
indexTiles?: PlanTile[];
/** Node pressure tags as in practice drawings (수두고/계획고 · 수압/유출량). */
pressureTags?: 'none' | 'kgf' | 'm';
font?: string;
/** Legend look and title band colour. */
legendStyle?: LegendStyle;
legendBand?: string;
}
const fmt = (v: number, d = 1) => v.toLocaleString('en-US', { maximumFractionDigits: d });
const pipeWidth = (d: number, k: number) => Math.min(1.1, 0.2 + d / 700) * k;
function niceStep(range: number, target: number): 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;
}
type Rect = { x0: number; y0: number; x1: number; y1: number };
/** Liang–Barsky clip of a polyline to a rectangle; returns the inside pieces. */
export function clipPolyline(pts: Array<[number, number]>, r: Rect): Array<Array<[number, number]>> {
const out: Array<Array<[number, number]>> = [];
let cur: Array<[number, number]> | null = null;
for (let i = 0; i < pts.length - 1; i++) {
const [x0, y0] = pts[i], [x1, y1] = pts[i + 1];
const dx = x1 - x0, dy = y1 - y0;
let t0 = 0, t1 = 1, ok = true;
for (const [pp, qq] of [[-dx, x0 - r.x0], [dx, r.x1 - x0], [-dy, y0 - r.y0], [dy, r.y1 - y0]] as const) {
if (pp === 0) { if (qq < 0) { ok = false; break; } continue; }
const t = qq / pp;
if (pp < 0) { if (t > t1) { ok = false; break; } if (t > t0) t0 = t; } else { if (t < t0) { ok = false; break; } if (t < t1) t1 = t; }
}
if (!ok) { cur = null; continue; }
const a: [number, number] = [x0 + dx * t0, y0 + dy * t0], c: [number, number] = [x0 + dx * t1, y0 + dy * t1];
if (cur && t0 === 0) cur.push(c);
else { cur = [a, c]; out.push(cur); }
if (t1 < 1) cur = null;
}
return out;
}
/**
* Chains of pipes with the same diameter joined through plain degree-2 points,
* so a label reads "D300 L=4,170m" for the whole stretch instead of per pipe.
*/
export function pipeChains(links: PlanLink[], stops: Set<string> = new Set()): Array<{ links: PlanLink[]; length: number; diameter: number }> {
const key = ([x, y]: [number, number]) => `${x.toFixed(3)},${y.toFixed(3)}`;
const pipes = links.filter((l) => l.kind === 'pipe' && l.diameter && l.pts.length >= 2);
const deg = new Map<string, number>();
for (const l of links) for (const p of [l.pts[0], l.pts[l.pts.length - 1]]) deg.set(key(p), (deg.get(key(p)) ?? 0) + 1);
const at = new Map<string, PlanLink[]>();
for (const l of pipes) for (const p of [l.pts[0], l.pts[l.pts.length - 1]]) at.set(key(p), [...(at.get(key(p)) ?? []), l]);
const used = new Set<string>();
const out: Array<{ links: PlanLink[]; length: number; diameter: number }> = [];
const passable = (k: string) => deg.get(k) === 2 && !stops.has(k);
for (const start of pipes) {
if (used.has(start.id)) continue;
used.add(start.id);
const chain = [start];
// grow from both ends
for (const end of [start.pts[0], start.pts[start.pts.length - 1]]) {
let k = key(end);
let cur = start;
while (passable(k)) {
const next = (at.get(k) ?? []).find((l) => l !== cur && !used.has(l.id) && l.diameter === start.diameter && !!l.closed === !!start.closed);
if (!next) break;
used.add(next.id);
chain.push(next);
const a = key(next.pts[0]), b = key(next.pts[next.pts.length - 1]);
k = a === k ? b : a;
cur = next;
}
}
out.push({ links: chain, length: chain.reduce((s, l) => s + (l.length ?? 0), 0), diameter: start.diameter! });
}
return out;
}
export function buildNetworkDrawing(input: PlanSheetInput): { drawing: Drawing; scale: number; tagsSkipped: number } {
const sheet = SHEETS[input.sheet];
const k = sheet.w / 420;
const b = new DrawingBuilder(sheet.w, sheet.h, input.info.title);
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
const grow = (x: number, y: number) => { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; };
if (input.view) { grow(input.view.minX, input.view.minY); grow(input.view.maxX, input.view.maxY); }
else {
for (const n of input.nodes) grow(n.x, n.y);
for (const l of input.links) for (const [x, y] of l.pts) grow(x, y);
}
if (!Number.isFinite(minX)) throw new Error('그릴 관망이 없습니다.');
const tmp = new DrawingBuilder(sheet.w, sheet.h, '');
const area0 = sheetFrame(tmp, { ...input.info, scale: '' });
// Map frame below the heading.
const area = { x0: area0.x0, x1: area0.x1, y0: area0.y0, y1: area0.y1 - 17 * k };
const pad = 8 * k;
const aw = area.x1 - area.x0 - 2 * pad, ah = area.y1 - area.y0 - 2 * pad;
const ext = Math.max((maxX - minX) / aw, (maxY - minY) / ah) * 1000;
const scale = input.metric ? input.scale ?? niceScale(Math.max(ext, 1e-9) / 1000 * aw, aw) : 0;
const unitsPerMm = input.metric ? scale / 1000 : input.view ? Math.max((maxX - minX) / (area.x1 - area.x0), (maxY - minY) / (area.y1 - area.y0)) || 1 : Math.max((maxX - minX) / aw, (maxY - minY) / ah) || 1;
const cx = (area.x0 + area.x1) / 2, cy = (area.y0 + area.y1) / 2;
const mx = (minX + maxX) / 2, my = (minY + maxY) / 2;
const P = (x: number, y: number): [number, number] => [cx + (x - mx) / unitsPerMm, cy + (y - my) / unitsPerMm];
const W = (px: number, py: number): [number, number] => [mx + (px - cx) * unitsPerMm, my + (py - cy) * unitsPerMm];
const scaleTxt = input.metric ? `S = 1:${scale.toLocaleString()}` : 'S = NONE';
sheetFrame(b, { ...input.info, scale: input.metric ? `1:${scale.toLocaleString()}` : 'NONE' });
sheetHeading(b, area0, input.sheetLabel ? `${input.info.title} ${input.sheetLabel}` : input.info.title, scaleTxt);
b.rect('MAPFRAME', area.x0, area.y0, area.x1 - area.x0, area.y1 - area.y0, { width: 0.45 });
const inside = ([x, y]: [number, number]) => x >= area.x0 && x <= area.x1 && y >= area.y0 && y <= area.y1;
for (const line of input.background ?? []) {
for (const piece of clipPolyline(line.map(([x, y]) => P(x, y)), area)) b.line('BACKGROUND', piece, { width: 0.1, color: '#a8b0bc' });
}
// Coordinate grid (Korean survey convention: X = northing, Y = easting).
if (input.metric) {
const [wx0, wy0] = W(area.x0, area.y0), [wx1, wy1] = W(area.x1, area.y1);
const step = niceStep(Math.max(wx1 - wx0, wy1 - wy0), 5);
const fs = 1.8 * k;
for (let gx = Math.ceil(wx0 / step) * step; gx < wx1; gx += step) {
const [px] = P(gx, my);
if (px - area.x0 < 6 * k || area.x1 - px < 6 * k) continue;
b.line('GRID', [[px, area.y0], [px, area.y1]], { width: 0.08, color: '#cbd5e1' });
b.line('GRID', [[px, area.y1], [px, area.y1 - 2 * k]], { width: 0.25 });
b.line('GRID', [[px, area.y0], [px, area.y0 + 2 * k]], { width: 0.25 });
b.text('GRID', px - 0.8 * k, area.y1 - 2.5 * k, `Y=${fmt(gx, 0)}`, fs, { rotate: -90, color: '#475569' });
}
for (let gy = Math.ceil(wy0 / step) * step; gy < wy1; gy += step) {
const [, py] = P(mx, gy);
if (py - area.y0 < 6 * k || area.y1 - py < 6 * k) continue;
b.line('GRID', [[area.x0, py], [area.x1, py]], { width: 0.08, color: '#cbd5e1' });
b.line('GRID', [[area.x0, py], [area.x0 + 2 * k, py]], { width: 0.25 });
b.line('GRID', [[area.x1, py], [area.x1 - 2 * k, py]], { width: 0.25 });
// left labels stay clear of the legend / scale bar block
if (py > area.y0 + (input.legend.length + 6) * 4.4 * k + 22 * k) b.text('GRID', area.x0 + 2.5 * k, py + 0.8 * k, `X=${fmt(gy, 0)}`, fs, { color: '#475569' });
b.text('GRID', area.x1 - 2.5 * k, py + 0.8 * k, `X=${fmt(gy, 0)}`, fs, { anchor: 'end', color: '#475569' });
}
}
// Pipes: width grows with diameter; closed pipes dashed.
for (const l of input.links) {
if (l.kind !== 'pipe') continue;
for (const piece of clipPolyline(l.pts.map(([x, y]) => P(x, y)), area)) b.line('PIPE', piece, { width: pipeWidth(l.diameter ?? 100, k), color: l.color, dash: l.closed ? 'dashed' : undefined });
}
// Valves / pumps oriented along their link (or the neighbouring pipe).
const dirAt = (l: PlanLink): number => {
const [ax, ay] = P(...l.pts[0]), [bx, by] = P(...l.pts[l.pts.length - 1]);
if (Math.hypot(bx - ax, by - ay) > 0.2) return Math.atan2(by - ay, bx - ax);
const near = input.links.find((o) => o.kind === 'pipe' && o.pts.length >= 2 && [o.pts[0], o.pts[o.pts.length - 1]].some((p) => Math.hypot(p[0] - l.pts[0][0], p[1] - l.pts[0][1]) < 1e-6));
if (!near) return 0;
const [cx2, cy2] = P(...near.pts[0]), [dx2, dy2] = P(...near.pts[1]);
return Math.atan2(dy2 - cy2, dx2 - cx2);
};
const usedKinds = new Set<string>();
const valveLabels: Array<{ x: number; y: number; nx: number; ny: number; text: string }> = [];
const symbolBoxes: Array<[number, number, number, number]> = [];
for (const l of input.links) {
if (l.kind === 'pipe') continue;
const pp = l.pts.map(([x, y]) => P(x, y));
const [ax, ay] = pp[0], [bx, by] = pp[pp.length - 1];
const c: [number, number] = [(ax + bx) / 2, (ay + by) / 2];
if (!inside(c)) continue;
b.line(l.kind === 'pump' ? 'PUMP' : 'VALVE', pp, { width: 0.4 * k, color: '#111111' });
const a = dirAt(l), ux = Math.cos(a), uy = Math.sin(a), nx = -uy, ny = ux;
const s = 1.5 * k;
if (l.kind === 'valve') {
const filled = ['PRV', 'PSV', 'PBV'].includes(l.subtype ?? '');
usedKinds.add(filled ? 'valveF' : 'valve');
for (const sg of [-1, 1]) {
const tri: Array<[number, number]> = [[c[0] + sg * ux * s + nx * s * 0.75, c[1] + sg * uy * s + ny * s * 0.75], [c[0] + sg * ux * s - nx * s * 0.75, c[1] + sg * uy * s - ny * s * 0.75], c];
b.fill('VALVE', tri, filled ? '#111111' : '#ffffff');
b.line('VALVE', tri, { width: 0.25, closed: true });
}
if (l.subtype) valveLabels.push({ x: c[0], y: c[1], nx, ny, text: l.subtype });
symbolBoxes.push([c[0] - s, c[1] - s, c[0] + s, c[1] + s]);
} else {
usedKinds.add('pump');
b.circle('PUMP', c[0], c[1], s * 1.15, { fill: '#ffffff', color: '#111111', width: 0.3 });
symbolBoxes.push([c[0] - s * 1.2, c[1] - s * 1.2, c[0] + s * 1.2, c[1] + s * 1.2]);
b.fill('PUMP', [[c[0] - ux * s * 0.5 + nx * s * 0.6, c[1] - uy * s * 0.5 + ny * s * 0.6], [c[0] - ux * s * 0.5 - nx * s * 0.6, c[1] - uy * s * 0.5 - ny * s * 0.6], [c[0] + ux * s * 0.7, c[1] + uy * s * 0.7]], '#111111');
}
}
// Label collision boxes: map furniture first.
const boxes: Array<[number, number, number, number]> = [...symbolBoxes];
const free = (x0: number, y0: number, x1: number, y1: number) => x0 > area.x0 && x1 < area.x1 && y0 > area.y0 && y1 < area.y1 && boxes.every(([a, c, e, g]) => x1 < a || x0 > e || y1 < c || y0 > g);
// Nodes: small junction dots, problem nodes emphasised, sources as symbols.
for (const n of input.nodes) {
const [x, y] = P(n.x, n.y);
if (!inside([x, y])) continue;
if (n.type === 'RESERVOIR') {
b.fill('SOURCE', [[x - 2.4 * k, y - 1.7 * k], [x + 2.4 * k, y - 1.7 * k], [x + 2.4 * k, y + 1.7 * k], [x - 2.4 * k, y + 1.7 * k]], '#0369a1');
b.rect('SOURCE', x - 2.4 * k, y - 1.7 * k, 4.8 * k, 3.4 * k, { width: 0.3 });
boxes.push([x - 2.6 * k, y - 1.9 * k, x + 2.6 * k, y + 1.9 * k]);
} else if (n.type === 'TANK') {
b.circle('SOURCE', x, y, 2.1 * k, { fill: '#0369a1', color: '#111111', width: 0.3 });
boxes.push([x - 2.3 * k, y - 2.3 * k, x + 2.3 * k, y + 2.3 * k]);
} else if (n.alert) {
b.circle('NODE_ALERT', x, y, 1.2 * k, { color: n.color, width: 0.3 });
boxes.push([x - 1.3 * k, y - 1.3 * k, x + 1.3 * k, y + 1.3 * k]);
b.circle('NODE_ALERT', x, y, 0.6 * k, { fill: n.color, color: n.color, width: 0.1 });
} else {
b.circle('NODE', x, y, 0.32 * k, { fill: '#475569', color: '#475569', width: 0.05 });
boxes.push([x - 0.5 * k, y - 0.5 * k, x + 0.5 * k, y + 0.5 * k]);
}
}
// North arrow (compass style, top-right inside the map frame).
{
const nx = area.x1 - 13 * k, ny = area.y1 - 15 * k, r = 6 * k;
b.circle('NORTH', nx, ny, r * 0.55, { width: 0.25 });
b.fill('NORTH', [[nx, ny + r], [nx - r * 0.22, ny], [nx, ny]], '#111111');
b.line('NORTH', [[nx, ny + r], [nx + r * 0.22, ny], [nx, ny]], { width: 0.25, closed: true });
b.fill('NORTH', [[nx, ny - r], [nx + r * 0.22, ny], [nx, ny]], '#111111');
b.line('NORTH', [[nx, ny - r], [nx - r * 0.22, ny], [nx, ny]], { width: 0.25, closed: true });
b.line('NORTH', [[nx - r * 0.8, ny], [nx + r * 0.8, ny]], { width: 0.2 });
b.text('NORTH', nx, ny + r + 2.2 * k, 'N', 3.2 * k, { anchor: 'middle', vAnchor: 'middle', bold: true });
boxes.push([nx - r, ny - r, nx + r, ny + r + 4 * k]);
}
// Legend panel (bottom-left inside the map frame) with the scale bar below.
const rowH = 3.9 * k;
type Item = { label: string; draw: (x: number, y: number) => void; group: string; h?: number };
const pipeHead = /관경/.test(input.legendTitle) ? '관경' : '유속';
const items: Item[] = input.legend.map((it) => ({
label: it.label, group: it.shape === 'line' ? pipeHead : '수압',
draw: (x, y) => (it.shape === 'line'
? b.line('LEGEND', [[x, y], [x + 10 * k, y]], { width: it.diameter ? pipeWidth(it.diameter, k) : 0.7 * k, color: it.color })
: b.circle('LEGEND', x + 5 * k, y, 0.9 * k, { fill: it.color, color: '#ffffff', width: 0.15 })),
}));
const fac = (label: string, draw: Item['draw']) => items.push({ label, draw, group: '시설' });
if (input.nodes.some((n) => n.type === 'RESERVOIR')) fac('배수지·정수지', (x, y) => { b.fill('LEGEND', [[x + 3 * k, y - 1.3 * k], [x + 7 * k, y - 1.3 * k], [x + 7 * k, y + 1.3 * k], [x + 3 * k, y + 1.3 * k]], '#0369a1'); });
if (input.nodes.some((n) => n.type === 'TANK')) fac('탱크', (x, y) => b.circle('LEGEND', x + 5 * k, y, 1.5 * k, { fill: '#0369a1', color: '#111111', width: 0.3 }));
const bow = (x: number, y: number, fill: string) => {
const c = x + 5 * k, s = 1.4 * k;
for (const sg of [-1, 1]) {
const t: Array<[number, number]> = [[c + sg * s, y + s * 0.75], [c + sg * s, y - s * 0.75], [c, y]];
b.fill('LEGEND', t, fill);
b.line('LEGEND', t, { width: 0.25, closed: true });
}
};
if (usedKinds.has('valveF')) fac('감압·지속밸브 (PRV 등)', (x, y) => bow(x, y, '#111111'));
if (usedKinds.has('valve')) fac('조절밸브 (FCV·TCV 등)', (x, y) => bow(x, y, '#ffffff'));
if (usedKinds.has('pump')) fac('가압펌프', (x, y) => { b.circle('LEGEND', x + 5 * k, y, 1.6 * k, { fill: '#ffffff', color: '#111111', width: 0.3 }); b.fill('LEGEND', [[x + 4.3 * k, y - 0.9 * k], [x + 4.3 * k, y + 0.9 * k], [x + 6.1 * k, y]], '#111111'); });
if (input.links.some((l) => l.closed)) items.push({ label: '폐쇄 관로', group: pipeHead, draw: (x, y) => b.line('LEGEND', [[x, y], [x + 10 * k, y]], { width: 0.4 * k, color: '#64748b', dash: 'dashed' }) });
// Sample panel: how a pipe label and a pressure tag read (as on practice drawings).
const withTags = !!input.pressureTags && input.pressureTags !== 'none';
const withPipe = input.pipeLabels !== 'none';
const sample = withTags || withPipe ? {
h: (withPipe ? 9 * k : 0) + (withTags ? 11 * k : 0) + 2 * k,
draw: (x0: number, y0: number, w: number, h: number) => {
let y = y0 + h - 1 * k;
if (withPipe) {
const cyy = y - 5 * k, ax = x0 + 10 * k, bx = x0 + w - 10 * k;
b.line('LEGEND', [[ax, cyy], [bx, cyy]], { width: 0.35, color: input.legend.find((it) => it.shape === 'line')?.color ?? '#1d4ed8' });
b.circle('LEGEND', ax, cyy, 0.9 * k, { fill: '#111111', color: '#111111' });
b.circle('LEGEND', bx, cyy, 0.9 * k, { fill: '#111111', color: '#111111' });
const lab = input.pipeLabels === 'IDDL' ? 'P-1-D200 L=85.30m' : input.pipeLabels === 'DL' ? 'D200 L=85.30m' : 'D200';
b.text('LEGEND', (ax + bx) / 2, cyy + 1.6 * k, lab, 1.9 * k, { anchor: 'middle', color: '#111111' });
const cap = input.pipeLabels === 'IDDL' ? '관로번호 - 관경(mm) 연장(m)' : input.pipeLabels === 'DL' ? '관경(mm) 연장(m)' : '관경(mm)';
b.text('LEGEND', (ax + bx) / 2, cyy - 1.4 * k, cap, 1.5 * k, { anchor: 'middle', vAnchor: 'top', color: '#475569' });
y -= 9 * k;
}
if (withTags) {
const unit = input.pressureTags === 'kgf' ? 'kg/cm²' : 'm';
const R = 3.4 * k, cx0 = x0 + 14 * k, cyy = y - 5.5 * k;
b.circle('LEGEND', cx0, cyy, R, { fill: '#e7e2dc', color: '#22d3ee', width: 0.25 });
b.line('LEGEND', [[cx0 - R, cyy], [cx0 + R, cyy]], { width: 0.15, color: '#22d3ee' });
b.text('LEGEND', cx0, cyy + R * 0.45, '동수두', 1.25 * k, { anchor: 'middle', vAnchor: 'middle' });
b.text('LEGEND', cx0, cyy - R * 0.45, '지반고', 1.25 * k, { anchor: 'middle', vAnchor: 'middle' });
b.line('LEGEND', [[cx0 + R, cyy], [cx0 + R + 18 * k, cyy]], { width: 0.15, color: '#22d3ee' });
b.text('LEGEND', cx0 + R + 1 * k, cyy + 1 * k, `수압 (${unit})`, 1.7 * k, { vAnchor: 'middle', color: '#1d4ed8' });
b.text('LEGEND', cx0 + R + 1 * k, cyy - 1.2 * k, '유출량 (m³/d)', 1.7 * k, { vAnchor: 'middle', color: '#1d4ed8' });
b.text('LEGEND', cx0 - R - 1.2 * k, cyy, '절점', 1.5 * k, { anchor: 'end', vAnchor: 'middle', color: '#475569' });
}
},
} : undefined;
// Stable group order: pipes, nodes, facilities.
const order = [pipeHead, '수압', '시설'];
items.sort((a, c) => order.indexOf(a.group) - order.indexOf(c.group));
const lw = 64 * k;
const lx = area.x0 + 4 * k;
let base = area.y0 + 4 * k;
if (input.metric) {
const target = (40 * k * scale) / 1000;
const mag = Math.pow(10, Math.floor(Math.log10(target)));
const len = [1, 2, 5, 10].map((m) => m * mag).filter((v) => v <= target).pop() ?? mag;
const bw = (len * 1000) / scale;
const x0 = lx + 2 * k, y0 = base + 3 * k;
b.fill('SCALEBAR', [[lx, base], [lx + Math.max(lw, bw + 14 * k), base], [lx + Math.max(lw, bw + 14 * k), base + 11 * k], [lx, base + 11 * k]], '#ffffff', 0.9);
for (let i = 0; i < 4; i++) {
const seg: Array<[number, number]> = [[x0 + (bw / 4) * i, y0], [x0 + (bw / 4) * (i + 1), y0], [x0 + (bw / 4) * (i + 1), y0 + 1.3 * k], [x0 + (bw / 4) * i, y0 + 1.3 * k]];
if (i % 2 === 0) b.fill('SCALEBAR', seg, '#111111');
b.line('SCALEBAR', seg, { width: 0.2, closed: true });
}
b.text('SCALEBAR', x0, y0 + 2.6 * k, '0', 1.7 * k, { anchor: 'middle' });
b.text('SCALEBAR', x0 + bw / 2, y0 + 2.6 * k, fmt(len / 2, 1), 1.7 * k, { anchor: 'middle' });
b.text('SCALEBAR', x0 + bw, y0 + 2.6 * k, fmt(len, 0), 1.7 * k, { anchor: 'middle' });
b.text('SCALEBAR', x0 + bw + 2.5 * k, y0 + 0.65 * k, '(m)', 1.7 * k, { vAnchor: 'middle' });
b.text('SCALEBAR', x0, y0 - 1.6 * k, scaleTxt, 1.8 * k, { vAnchor: 'top', bold: true });
base += 13 * k;
}
const groupsArr = order.map((name) => ({ name, rows: items.filter((it) => it.group === name).map((it) => ({ label: it.label, draw: it.draw })) }));
const leg = legendTable(b, 'LEGEND', groupsArr, { w: lw, sample, style: input.legendStyle ?? 'table', band: input.legendBand });
if (leg.h) {
boxes.push([lx - 1 * k, area.y0, lx + lw + 2 * k, base + leg.h + 3 * k]);
leg.draw(lx, base + 1 * k);
}
// Index sheet: tile rectangles and numbers.
for (const t of input.indexTiles ?? []) {
const [x0, y0] = P(t.minX, t.minY), [x1, y1] = P(t.maxX, t.maxY);
b.fill('INDEX', [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], '#fee2e2', 0.25);
b.rect('INDEX', x0, y0, x1 - x0, y1 - y0, { width: 0.35, color: '#dc2626' });
const size = Math.min(5 * k, Math.max(2.2 * k, (y1 - y0) * 0.3));
const tw = t.name.length * size * 0.65 + 2 * k;
const cx0 = (x0 + x1) / 2, cy0 = (y0 + y1) / 2;
b.fill('INDEX', [[cx0 - tw / 2, cy0 - size * 0.7], [cx0 + tw / 2, cy0 - size * 0.7], [cx0 + tw / 2, cy0 + size * 0.7], [cx0 - tw / 2, cy0 + size * 0.7]], '#ffffff', 0.85);
b.text('INDEX', cx0, cy0, t.name, size, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#dc2626' });
boxes.push([cx0 - tw / 2, cy0 - size * 0.7, cx0 + tw / 2, cy0 + size * 0.7]);
}
// Location inset (위치도) at the bottom-right of detail sheets.
if (input.keyMap && input.keyMap.tiles.length) {
const km = input.keyMap;
const kw = 56 * k, kh = 38 * k, kx = area.x1 - kw - 3 * k, ky = area.y0 + 3 * k;
panelHeader(b, 'KEYMAP', kx, ky + kh, kw, kh, '위 치 도');
let ax0 = Infinity, ay0 = Infinity, ax1 = -Infinity, ay1 = -Infinity;
for (const t of km.tiles) { ax0 = Math.min(ax0, t.minX); ay0 = Math.min(ay0, t.minY); ax1 = Math.max(ax1, t.maxX); ay1 = Math.max(ay1, t.maxY); }
const sc = Math.max((ax1 - ax0) / (kw - 6 * k), (ay1 - ay0) / (kh - 6 * k)) || 1;
const kcx = kx + kw / 2, kcy = ky + kh / 2, mcx = (ax0 + ax1) / 2, mcy = (ay0 + ay1) / 2;
const Q = (x: number, y: number): [number, number] => [kcx + (x - mcx) / sc, kcy + (y - mcy) / sc];
for (const line of km.lines) b.line('KEYMAP', line.map(([x, y]) => Q(x, y)), { width: 0.12, color: '#64748b' });
for (const t of km.tiles) {
const [x0, y0] = Q(t.minX, t.minY), [x1, y1] = Q(t.maxX, t.maxY);
if (t.no === km.current) b.fill('KEYMAP', [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], '#dc2626', 0.35);
b.rect('KEYMAP', x0, y0, x1 - x0, y1 - y0, { width: t.no === km.current ? 0.35 : 0.1, color: t.no === km.current ? '#dc2626' : '#94a3b8' });
if (km.tiles.length <= 60 && (x1 - x0) > 2.2 * k) b.text('KEYMAP', (x0 + x1) / 2, (y0 + y1) / 2, t.name, Math.min(1.6 * k, (y1 - y0) * 0.6), { anchor: 'middle', vAnchor: 'middle', color: t.no === km.current ? '#dc2626' : '#64748b', bold: t.no === km.current });
}
boxes.push([kx - 1 * k, area.y0, area.x1, ky + kh + 6 * k]);
}
// Adjacent sheet references (접속도면): edge middles and corners, by grid direction.
for (const nb of input.neighbours ?? []) {
const corner = nb.dx !== 0 && nb.dy !== 0;
const arrow = corner ? (nb.dy > 0 ? (nb.dx > 0 ? '↗' : '↖') : nb.dx > 0 ? '↘' : '↙') : nb.dx > 0 ? '▶' : nb.dx < 0 ? '◀' : nb.dy > 0 ? '▲' : '▼';
const text = corner ? `${arrow} ${nb.name}` : `${arrow} 도면 ${nb.name} 연결`;
const size = 2 * k, tw = text.length * size * 0.7 + 2 * k, th = size * 1.6;
const pos = (d: number, lo: number, hi: number, half: number) => (d < 0 ? lo + half + 1.5 * k : d > 0 ? hi - half - 1.5 * k : (lo + hi) / 2);
const bx = pos(nb.dx, area.x0, area.x1, tw / 2), by = pos(nb.dy, area.y0, area.y1, th / 2);
// Edge refs slide along their edge, corner refs inward, until clear of the legend / inset / other refs.
let ex = bx, ey = by;
for (const sh of [0, 1, -1, 2, -2, 3, -3, 4, -4, 6, -6, 8, -8, 10, -10, 12, -12, 14, -14, 16, -16].map((v) => v * 6 * k)) {
if (corner && sh < 0) continue;
const qx = nb.dx === 0 ? Math.min(Math.max(bx + sh, area.x0 + tw / 2 + 2 * k), area.x1 - tw / 2 - 2 * k) : bx;
const qy = nb.dx === 0 ? by : corner ? by - Math.sign(nb.dy) * sh : Math.min(Math.max(by + sh, area.y0 + th), area.y1 - th);
if (boxes.every(([a0, c0, e0, g0]) => qx + tw / 2 < a0 || qx - tw / 2 > e0 || qy + th / 2 < c0 || qy - th / 2 > g0)) { ex = qx; ey = qy; break; }
}
b.fill('MATCH', [[ex - tw / 2, ey - th / 2], [ex + tw / 2, ey - th / 2], [ex + tw / 2, ey + th / 2], [ex - tw / 2, ey + th / 2]], '#ffffff', 0.9);
b.rect('MATCH', ex - tw / 2, ey - th / 2, tw, th, { width: 0.25, color: '#7c3aed' });
b.text('MATCH', ex, ey, text, size, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#7c3aed' });
boxes.push([ex - tw / 2, ey - th / 2, ex + tw / 2, ey + th / 2]);
}
// Source labels (always win), then pressure tags, pipe labels, junction labels.
for (const n of input.nodes) {
const show = input.nodeLabels === 'all' || (input.nodeLabels === 'sources' && n.type !== 'JUNCTION');
if (!show || n.type === 'JUNCTION') continue;
const [x, y] = P(n.x, n.y);
const t = n.label ?? n.id, size = 2.6 * k;
const tw = t.length * size * 0.75 + 2 * k;
// First corner (NE, NW, SE, SW) where the box does not hit nodes or symbols.
const corners: Array<[number, number]> = [[x + 3.2 * k, y + 2.2 * k], [x - 3.2 * k - tw, y + 2.2 * k], [x + 3.2 * k, y - 2.2 * k], [x - 3.2 * k - tw, y - 2.2 * k], [x - tw / 2, y + 4.5 * k], [x - tw / 2, y - 4.5 * k]];
const [bx0, by] = corners.find(([cx0, cy0]) => free(cx0, cy0 - size * 0.75, cx0 + tw, cy0 + size * 0.75)) ?? corners[0];
b.fill('SOURCE', [[bx0, by - size * 0.75], [bx0 + tw, by - size * 0.75], [bx0 + tw, by + size * 0.75], [bx0, by + size * 0.75]], '#ffffff', 0.9);
b.rect('SOURCE', bx0, by - size * 0.75, tw, size * 1.5, { width: 0.3, color: '#0369a1' });
b.text('SOURCE', bx0 + tw / 2, by, t, size, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#0c4a6e' });
boxes.push([bx0, by - size * 0.75, bx0 + tw, by + size * 0.75]);
}
let tagsSkipped = 0;
if (input.pressureTags && input.pressureTags !== 'none') {
const R = 3.2 * k, fs = 1.6 * k;
const kgf = input.pressureTags === 'kgf';
const dirs = [45, 135, -45, -135, 90, -90, 0, 180].map((d) => (d * Math.PI) / 180);
for (const n of input.nodes) {
if (n.type !== 'JUNCTION' || n.pressure === undefined || !Number.isFinite(n.pressure)) continue;
const [x, y] = P(n.x, n.y);
if (!inside([x, y])) continue;
const pTxt = `${(kgf ? n.pressure / 10 : n.pressure).toFixed(2)} ${kgf ? 'kg/cm²' : 'm'}`;
const dTxt = n.demand !== undefined && Math.abs(n.demand) > 1e-6 ? n.demand.toFixed(2) : '-';
const tw = Math.max(pTxt.length, dTxt.length) * fs * 0.56 + 1.2 * k;
let spot: [number, number] | null = null;
for (const dist of [R + 1.6 * k, R + 4 * k, R + 7.5 * k]) {
for (const a of dirs) {
const cx0 = x + Math.cos(a) * dist, cy0 = y + Math.sin(a) * dist;
if (free(cx0 - R, cy0 - R, cx0 + R + tw, cy0 + R)) { spot = [cx0, cy0]; break; }
}
if (spot) break;
}
if (!spot) { tagsSkipped++; continue; }
const [tx, ty] = spot;
boxes.push([tx - R, ty - R, tx + R + tw, ty + R]);
const d = Math.hypot(tx - x, ty - y);
b.line('PRESSURE', [[x, y], [tx - ((tx - x) / d) * R, ty - ((ty - y) / d) * R]], { width: 0.12, color: '#0891b2' });
b.circle('PRESSURE', tx, ty, R, { fill: '#e7e2dc', color: '#22d3ee', width: 0.15 });
b.line('PRESSURE', [[tx - R, ty], [tx + R + tw, ty]], { width: 0.12, color: '#22d3ee' });
const blue = '#1d4ed8';
b.text('PRESSURE', tx, ty + R * 0.47, n.head !== undefined ? n.head.toFixed(2) : '-', fs, { anchor: 'middle', vAnchor: 'middle', color: blue });
b.text('PRESSURE', tx, ty - R * 0.47, n.elevation !== undefined ? n.elevation.toFixed(2) : '-', fs, { anchor: 'middle', vAnchor: 'middle', color: blue });
b.text('PRESSURE', tx + R + 0.8 * k, ty + R * 0.47, pTxt, fs, { vAnchor: 'middle', color: n.alert ? '#dc2626' : blue, bold: !!n.alert });
b.text('PRESSURE', tx + R + 0.8 * k, ty - R * 0.47, dTxt, fs, { vAnchor: 'middle', color: blue });
}
}
if (input.pipeLabels !== 'none') {
const minMm = input.labelMinMm ?? 18 * k;
const stops = new Set(input.nodes.filter((n) => n.type !== 'JUNCTION').map((n) => `${n.x.toFixed(3)},${n.y.toFixed(3)}`));
const groups = input.view
? input.links.filter((l) => l.kind === 'pipe' && l.diameter).map((l) => ({ links: [l], length: l.length ?? 0, diameter: l.diameter! }))
: pipeChains(input.links, stops);
const chains = groups.map((c) => {
// Longest single leg on paper across the chain.
let best = 0, seg: [[number, number], [number, number]] | null = null, tot = 0;
for (const l of c.links) {
const pp = l.pts.map(([x, y]) => P(x, y));
for (let i = 0; i < pp.length - 1; i++) {
const d = Math.hypot(pp[i + 1][0] - pp[i][0], pp[i + 1][1] - pp[i][1]);
const mid: [number, number] = [(pp[i][0] + pp[i + 1][0]) / 2, (pp[i][1] + pp[i + 1][1]) / 2];
if (!inside(mid)) continue;
tot += d;
if (d > best) { best = d; seg = [pp[i], pp[i + 1]]; }
}
}
return { ...c, best, seg, tot };
}).filter((c) => c.seg && c.tot >= minMm).sort((a, b2) => b2.diameter - a.diameter || b2.tot - a.tot);
for (const c of chains) {
const [a, e] = c.seg!;
const dl = `D${c.diameter.toFixed(0)} L=${fmt(c.length, input.view ? 2 : 0)}m`;
const text = input.pipeLabels === 'IDDL' && c.length ? `${c.links[0].id}-${dl}` : input.pipeLabels === 'DL' && c.length ? dl : `D${c.diameter.toFixed(0)}`;
const size = (c.diameter >= 300 ? 2 : 1.75) * k;
const half = text.length * size * 0.3;
// Along the longest straight leg when it fits; otherwise (winding pipe)
// horizontal beside it as long as the whole stretch is long enough.
if (half * 2 > c.tot * 0.9) continue;
let ang = half * 2 <= c.best * 1.15 ? (Math.atan2(e[1] - a[1], e[0] - a[0]) * 180) / Math.PI : 0;
if (ang > 90) ang -= 180;
if (ang < -90) ang += 180;
const mxp = (a[0] + e[0]) / 2, myp = (a[1] + e[1]) / 2;
const r = (ang * Math.PI) / 180;
const off = [-Math.sin(r) * (size * 0.9 + 0.6 * k), Math.cos(r) * (size * 0.9 + 0.6 * k)];
// Rotated label extents (axis-aligned box of the rotated rectangle).
const ex = Math.abs(Math.cos(r)) * half + Math.abs(Math.sin(r)) * size * 0.55;
const ey = Math.abs(Math.sin(r)) * half + Math.abs(Math.cos(r)) * size * 0.55;
const spot = [1, -1].map((sg) => [mxp + sg * off[0], myp + sg * off[1]] as const).find(([qx, qy]) => free(qx - ex, qy - ey, qx + ex, qy + ey));
if (!spot) continue;
const [lx, ly] = spot;
boxes.push([lx - ex, ly - ey, lx + ex, ly + ey]);
b.text('PIPE_LABEL', lx, ly, text, size, { anchor: 'middle', vAnchor: 'middle', rotate: ang, color: '#1e293b', bold: c.diameter >= 300 });
}
}
for (const v of valveLabels) {
const size = 1.45 * k, hw = v.text.length * size * 0.33;
const spot = [1, -1].map((sg) => [v.x + sg * v.nx * 2.8 * k, v.y + sg * v.ny * 2.8 * k] as const).find(([qx, qy]) => free(qx - hw, qy - size * 0.6, qx + hw, qy + size * 0.6));
if (!spot) continue;
boxes.push([spot[0] - hw, spot[1] - size * 0.6, spot[0] + hw, spot[1] + size * 0.6]);
b.text('VALVE', spot[0], spot[1], v.text, size, { anchor: 'middle', vAnchor: 'middle', bold: true, color: '#7c2d12' });
}
if (input.nodeLabels === 'all') {
for (const n of input.nodes) {
if (n.type !== 'JUNCTION') continue;
const [x, y] = P(n.x, n.y);
if (!inside([x, y])) continue;
const size = 1.5 * k;
const bx0 = x + 1.2 * k, bx1 = bx0 + n.id.length * size * 0.62, by0 = y - size / 2, by1 = y + size;
if (!free(bx0, by0, bx1, by1)) continue;
boxes.push([bx0, by0, bx1, by1]);
b.text('NODE_LABEL', bx0, y, n.id, size, { vAnchor: 'middle', color: n.alert ? n.color : '#475569' });
}
}
return { drawing: b.build(input.font), scale, tagsSkipped };
}
/** Map window of a sheet (mm on paper) for the given sheet size. */
function mapArea(sheetKey: keyof typeof SHEETS, info: Omit<TitleInfo, 'scale'>) {
const sheet = SHEETS[sheetKey];
const k = sheet.w / 420;
const tmp = new DrawingBuilder(sheet.w, sheet.h, '');
const a = sheetFrame(tmp, { ...info, scale: '' });
return { w: a.x1 - a.x0, h: a.y1 - 17 * k - a.y0, k };
}
const DETAIL_SCALES = [500, 600, 1000, 1200, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 10000, 15000, 20000];
/** Excel-style row letters: 0 -> A, 25 -> Z, 26 -> AA. */
function rowLetters(n: number): string {
let r = '';
for (let m = n + 1; m > 0; m = Math.floor((m - 1) / 26)) r = String.fromCharCode(65 + ((m - 1) % 26)) + r;
return r;
}
/**
* Split the network into detail sheets at one scale on a regular sheet grid
* (map book / atlas practice, as QGIS AtlasGrid, ATLAS Index Pro and ArcGIS
* Grid Index Features do): every sheet is the full map window, neighbouring
* sheets overlap by `overlap` of the window, the grid is shifted over a few
* offsets and the one needing the fewest sheets wins (ties: closest to
* centred on the network), and only cells the network passes through are
* kept. A cell is claimed by what falls in its core (window minus half the
* overlap on each side), so pipes that only touch the overlap strip do not
* add a sheet. Sheets are numbered row by row from the north-west; `name` is
* the serial number or the grid reference (A-1: row letter from the north,
* column number from the west). `auto` picks the scale from the typical
* junction spacing (room for a pressure tag) and coarsens it while the sheet
* count exceeds `maxSheets`.
*/
export function planLayout(
input: Pick<PlanSheetInput, 'sheet' | 'info' | 'links' | 'nodes'>,
opts: { scale: number | 'auto'; maxSheets?: number; overlap?: number; naming?: 'serial' | 'grid' },
): { scale: number; tiles: PlanTile[] } {
const { w, h, k } = mapArea(input.sheet, input.info);
const overlap = opts.overlap ?? 0.06;
const pts: Array<[number, number]> = input.nodes.map((n) => [n.x, n.y]);
for (const l of input.links) for (const p of l.pts) pts.push(p);
if (!pts.length) throw new Error('그릴 관망이 없습니다.');
const segs = input.links.flatMap((l) => l.pts.slice(1).map((p, i) => [l.pts[i], p] as const));
const cover = (scale: number): PlanTile[] => {
const fw = (w * scale) / 1000, fh = (h * scale) / 1000; // sheet window (model units)
const sw = fw * (1 - overlap), sh = fh * (1 - overlap); // grid step = core size
// densify long segments so a pipe crossing a cell claims it
const all = [...pts];
const step = Math.min(sw, sh) / 8;
for (const [a, c] of segs) {
const L = Math.hypot(c[0] - a[0], c[1] - a[1]);
for (let t = step; t < L; t += step) all.push([a[0] + ((c[0] - a[0]) * t) / L, a[1] + ((c[1] - a[1]) * t) / L]);
}
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [x, y] of all) { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y); }
// Offset that centres the grid on the network, then shifts around it.
const cx0 = x0 - (Math.max(1, Math.ceil((x1 - x0) / sw)) * sw - (x1 - x0)) / 2;
const cy0 = y0 - (Math.max(1, Math.ceil((y1 - y0) / sh)) * sh - (y1 - y0)) / 2;
let best: { cells: Set<string>; gx: number; gy: number; off: number } | null = null;
const shifts = [0, 0.125, -0.125, 0.25, -0.25, 0.375, -0.375, 0.5];
for (const ox of shifts) for (const oy of shifts) {
const gx = cx0 + ox * sw, gy = cy0 + oy * sh;
const cells = new Set<string>();
for (const [x, y] of all) cells.add(`${Math.floor((x - gx) / sw)},${Math.floor((y - gy) / sh)}`);
const off = Math.abs(ox) + Math.abs(oy);
if (!best || cells.size < best.cells.size || (cells.size === best.cells.size && off < best.off)) best = { cells, gx, gy, off };
}
const { cells, gx, gy } = best!;
const ij = [...cells].map((c) => c.split(',').map(Number) as [number, number]);
const imin = Math.min(...ij.map(([i]) => i)), jmax = Math.max(...ij.map(([, j]) => j));
ij.sort((a, b) => b[1] - a[1] || a[0] - b[0]); // rows from the north, west to east
const mx = (fw - sw) / 2, my = (fh - sh) / 2;
return ij.map(([i, j], n) => ({
no: n + 1, i, j,
name: opts.naming === 'grid' ? `${rowLetters(jmax - j)}-${i - imin + 1}` : String(n + 1),
minX: gx + i * sw - mx, maxX: gx + (i + 1) * sw + mx, minY: gy + j * sh - my, maxY: gy + (j + 1) * sh + my,
}));
};
const max = opts.maxSheets ?? 40;
if (opts.scale !== 'auto') return { scale: opts.scale, tiles: cover(opts.scale) };
// Typical junction spacing (median nearest neighbour, sampled).
const js = input.nodes.filter((n) => n.type === 'JUNCTION');
const sample = js.length > 1500 ? js.filter((_, i) => i % Math.ceil(js.length / 1500) === 0) : js;
const nn: number[] = [];
for (const a of sample) {
let m = Infinity;
for (const c of js) if (c !== a) { const d = Math.hypot(c.x - a.x, c.y - a.y); if (d > 1e-6 && d < m) m = d; }
if (Number.isFinite(m)) nn.push(m);
}
nn.sort((a, b) => a - b);
const med = nn.length ? nn[Math.floor(nn.length / 2)] : 100;
const want = (med * 1000) / (16 * k); // room for one pressure tag between neighbours
let idx = Math.max(0, DETAIL_SCALES.filter((sc) => sc <= want).length - 1);
let tiles = cover(DETAIL_SCALES[idx]);
while (tiles.length > max && idx < DETAIL_SCALES.length - 1) tiles = cover(DETAIL_SCALES[++idx]);
return { scale: DETAIL_SCALES[idx], tiles };
}
/** The (up to 8) grid neighbours of a sheet, with their direction. */
export function tileNeighbours(tiles: PlanTile[], t: PlanTile): PlanNeighbour[] {
return tiles.filter((o) => o !== t && Math.abs(o.i - t.i) <= 1 && Math.abs(o.j - t.j) <= 1)
.map((o) => ({ no: o.no, name: o.name, dx: o.i - t.i, dy: o.j - t.j }));
}
+376
View File
@@ -0,0 +1,376 @@
// 수리 종단면도 (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 };
}
+9
View File
@@ -145,6 +145,15 @@ export interface MapAnnotation {
color?: string;
}
/** DEM 등고선 on the map (maplibre-contour over the API's terrain tiles). */
export interface DemContours {
demId: string;
/** Minor contour interval (m); every 5th is a labelled major line. */
interval: number;
/** Highest useful tile zoom for this DEM's resolution. */
maxzoom: number;
}
/** 3D 보기: tilted map, optional DEM terrain and node columns. */
export interface View3D {
enabled: boolean;