Files
SPipe/src/networkTools.ts
T
bjkim1 21ebb16fb7 SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음)
- README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리
- 패치내역을 docs/로 이동, _worklog.md 추가
- 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
2026-10-30 11:00:46 +09:00

144 lines
5.4 KiB
TypeScript

// Whole-network editing tools (관망 도구): pipe length recalculation from
// geometry and elevation filling from the nearest node. Pure functions; the
// UI wraps them with undo.
import type { NetworkModel, InpNode } from './inpModel';
import { calculateGeoAwareDistance, type CRS } from './crs.ts';
import { getUnitSystem } from './unitSystem.ts';
export interface Point {
x: number;
y: number;
}
/** Link ID -> intermediate vertices, read from the raw [VERTICES] section. */
export function parseVertices(model: NetworkModel): Record<string, Point[]> {
if (model.vertices) return model.vertices;
const out: Record<string, Point[]> = {};
const r = model.sectionIndices['VERTICES'];
if (!r) return out;
for (let i = r.start + 1; i <= r.end; i++) {
const body = model.rawLines[i]?.split(';')[0].trim();
if (!body) continue;
const [id, xs, ys] = body.split(/\s+/);
const x = Number(xs);
const y = Number(ys);
if (id && Number.isFinite(x) && Number.isFinite(y)) (out[id] ??= []).push({ x, y });
}
return out;
}
/** Polyline length (node1 -> vertices -> node2) in the model's length units. */
export function linkGeometryLength(model: NetworkModel, node1: string, node2: string, vertices: Point[], crs: CRS): { length: number; isPhysical: boolean } | null {
const a = model.nodes[node1];
const b = model.nodes[node2];
if (!a || !b) return null;
const system = getUnitSystem(model.options.units);
const pts: Point[] = [a, ...vertices, b];
let total = 0;
let isPhysical = true;
for (let i = 1; i < pts.length; i++) {
const d = calculateGeoAwareDistance(pts[i - 1], pts[i], crs, system);
total += d.distance;
isPhysical = isPhysical && d.isPhysical;
}
return { length: total, isPhysical };
}
export interface LengthChange {
id: string;
before: number;
after: number;
}
/**
* Recalculate pipe lengths from coordinates (and [VERTICES]); `ids` limits the
* set. Lengths are rounded to 0.01 and a zero-length result keeps the old value.
*/
export function recalcPipeLengths(model: NetworkModel, crs: CRS, ids?: Iterable<string>): { model: NetworkModel; changes: LengthChange[]; isPhysical: boolean } {
const vertices = parseVertices(model);
const target = ids ? new Set(ids) : null;
const pipes = { ...model.pipes };
const changes: LengthChange[] = [];
let isPhysical = true;
for (const p of Object.values(model.pipes)) {
if (target && !target.has(p.id)) continue;
const g = linkGeometryLength(model, p.node1, p.node2, vertices[p.id] ?? [], crs);
if (!g) continue;
isPhysical = isPhysical && g.isPhysical;
const after = Math.round(g.length * 100) / 100;
if (after > 0 && Math.abs(after - p.length) >= 0.005) {
pipes[p.id] = { ...p, length: after };
changes.push({ id: p.id, before: p.length, after });
}
}
return { model: changes.length ? { ...model, pipes } : model, changes, isPhysical };
}
/** Pipes touching a node (for re-measuring after the node moved). */
export function pipesAtNode(model: NetworkModel, nodeId: string): string[] {
return Object.values(model.pipes)
.filter((p) => p.node1 === nodeId || p.node2 === nodeId)
.map((p) => p.id);
}
/** Elevation of the nearest other node (by planar coordinate distance), or null. */
export function nearestNodeElevation(model: NetworkModel, x: number, y: number, exclude?: Set<string>, onlyNonZero = false): number | null {
let best: InpNode | null = null;
let bestD = Infinity;
for (const n of Object.values(model.nodes)) {
if (exclude?.has(n.id)) continue;
if (onlyNonZero && !n.elevation) continue;
const d = (n.x - x) ** 2 + (n.y - y) ** 2;
if (d < bestD) {
bestD = d;
best = n;
}
}
return best ? best.elevation : null;
}
/** Junctions/tanks with elevation 0 take the nearest non-zero node's elevation. */
export function fillZeroElevations(model: NetworkModel): { model: NetworkModel; filled: string[] } {
const zero = Object.values(model.nodes).filter((n) => n.type !== 'RESERVOIR' && !n.elevation);
if (!zero.length) return { model, filled: [] };
const nodes = { ...model.nodes };
const filled: string[] = [];
const zeroIds = new Set(zero.map((n) => n.id));
for (const n of zero) {
const e = nearestNodeElevation(model, n.x, n.y, zeroIds, true);
if (e !== null) {
nodes[n.id] = { ...n, elevation: e };
filled.push(n.id);
}
}
return { model: filled.length ? { ...model, nodes } : model, filled };
}
// ---- edit options (persisted per browser) ----
export interface EditOptions {
/** Re-measure connected pipe lengths when a node moves (new pipes are always measured). */
autoLength: boolean;
/** New nodes take the nearest existing node's elevation instead of 0. */
nearestElevation: boolean;
}
const EDIT_OPTIONS_KEY = 'spipe_edit_options';
export const DEFAULT_EDIT_OPTIONS: EditOptions = { autoLength: true, nearestElevation: true };
export function loadEditOptions(): EditOptions {
try {
return { ...DEFAULT_EDIT_OPTIONS, ...JSON.parse(localStorage.getItem(EDIT_OPTIONS_KEY) || '{}') };
} catch {
return DEFAULT_EDIT_OPTIONS;
}
}
export function saveEditOptions(o: EditOptions): void {
try {
localStorage.setItem(EDIT_OPTIONS_KEY, JSON.stringify(o));
} catch {
// storage unavailable: options just won't persist
}
}