- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
144 lines
5.4 KiB
TypeScript
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
|
|
}
|
|
}
|