Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -526,9 +526,14 @@ def download_geodata(
|
||||
# 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함).
|
||||
prune_sheets(sheet_store, sheet_grid)
|
||||
|
||||
# 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록)가 같으면 스킵
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import PARSER_VERSION
|
||||
|
||||
# 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록 · 파서 판)가 같으면 스킵
|
||||
merge_key_path = processed_dir / "sheet_merge.key.json"
|
||||
merge_key = {"sheets": sorted(str(item) for item in sheet_result["available"])}
|
||||
merge_key = {
|
||||
"sheets": sorted(str(item) for item in sheet_result["available"]),
|
||||
"parser": PARSER_VERSION,
|
||||
}
|
||||
if sheet_result["available"] and (
|
||||
rebuild
|
||||
or not any(processed_dir.glob("도엽_*.geojson"))
|
||||
|
||||
@@ -37,6 +37,10 @@ SHEET_LAYERS: dict[str, tuple[str, str]] = {
|
||||
|
||||
# 산출 파일 접두사 (기존 processed/의 국가 GIS geojson과 구분)
|
||||
_OUTPUT_PREFIX = "도엽"
|
||||
# 파서 판 번호 — 병합 캐시 열쇠에 넣음 · 읽는 법이 바뀌면 올려 옛 병합본을 다시 지음
|
||||
PARSER_VERSION = 2
|
||||
# .cpg 없는 도엽 dbf 의 글자 인코딩(국토지리정보원 배포본)
|
||||
DBF_FALLBACK_ENCODING = "cp949"
|
||||
|
||||
|
||||
def _read_layer_from_zip(
|
||||
@@ -57,7 +61,9 @@ def _read_layer_from_zip(
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
for n in wanted:
|
||||
(Path(td) / Path(n).name).write_bytes(zf.read(n))
|
||||
gdf = gpd.read_file(Path(td) / f"{member_base}.shp")
|
||||
has_cpg = any(Path(n).suffix.lower() == ".cpg" for n in wanted)
|
||||
options = {} if has_cpg else {"encoding": DBF_FALLBACK_ENCODING}
|
||||
gdf = gpd.read_file(Path(td) / f"{member_base}.shp", **options)
|
||||
|
||||
if gdf.empty:
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""D 예정노선 수동 직선 그리기 셈 — [저장] 정본 쪽 (PLAN 562-1 · 요구 389 12 · 13).
|
||||
|
||||
구간 길이 · 원지반 기울기 · 길이 고정 · 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점.
|
||||
높이는 인자 `height_at(x, y)`(nan = 지형 밖).
|
||||
|
||||
⚠ 파이썬·TS 짝 — `logic/D03_PlanAnalysis_Draw.ts` 같은 이름(camel). 한쪽을 고치면 다른 쪽도 고치고
|
||||
거울 시험 `resources/tester/test_562_1_plan_draw.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
Vertex = tuple[float, float]
|
||||
HeightAt = Callable[[float, float], float]
|
||||
|
||||
# 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수 — 짝 TS 상수
|
||||
SLOPE_STEP_M = 1
|
||||
BISECT = 40
|
||||
COMPASS_SAMPLES = 360
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
def _finite(value: float) -> bool:
|
||||
return math.isfinite(value)
|
||||
|
||||
|
||||
def segment_length(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
"""수평 길이(m)."""
|
||||
dx = b[0] - a[0]
|
||||
dy = b[1] - a[1]
|
||||
return math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
|
||||
def ground_slope_pct(a: Sequence[float], b: Sequence[float], height_at: HeightAt) -> float | None:
|
||||
"""원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · None = 지형 밖 · 길이 0."""
|
||||
length = segment_length(a, b)
|
||||
if length < EPS:
|
||||
return None
|
||||
za = height_at(a[0], a[1])
|
||||
zb = height_at(b[0], b[1])
|
||||
if not _finite(za) or not _finite(zb):
|
||||
return None
|
||||
return ((zb - za) / length) * 100
|
||||
|
||||
|
||||
def fixed_length_point(
|
||||
start: Sequence[float], cursor: Sequence[float], length_m: float
|
||||
) -> Vertex | None:
|
||||
"""길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 None."""
|
||||
d = segment_length(start, cursor)
|
||||
if d < EPS:
|
||||
return None
|
||||
return (
|
||||
start[0] + ((cursor[0] - start[0]) / d) * length_m,
|
||||
start[1] + ((cursor[1] - start[1]) / d) * length_m,
|
||||
)
|
||||
|
||||
|
||||
def fixed_slope_point(
|
||||
start: Sequence[float],
|
||||
cursor: Sequence[float],
|
||||
slope_pct: float,
|
||||
height_at: HeightAt,
|
||||
max_m: float = 1000,
|
||||
) -> Vertex | None:
|
||||
"""기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) ·
|
||||
`max_m` 안에 없거나 지형 밖에 닿으면 None."""
|
||||
d = segment_length(start, cursor)
|
||||
z0 = height_at(start[0], start[1])
|
||||
if d < EPS or not _finite(z0):
|
||||
return None
|
||||
ux = (cursor[0] - start[0]) / d
|
||||
uy = (cursor[1] - start[1]) / d
|
||||
grade = abs(slope_pct) / 100
|
||||
|
||||
def at(t: float) -> Vertex:
|
||||
return (start[0] + ux * t, start[1] + uy * t)
|
||||
|
||||
def f(t: float) -> float:
|
||||
p = at(t)
|
||||
return abs(height_at(p[0], p[1]) - z0) - grade * t
|
||||
|
||||
lo = 0.0
|
||||
flo = math.nan
|
||||
i = 1
|
||||
while i * SLOPE_STEP_M <= max_m:
|
||||
t = i * SLOPE_STEP_M
|
||||
ft = f(t)
|
||||
if not _finite(ft):
|
||||
return None
|
||||
if ft == 0:
|
||||
return at(t)
|
||||
if i > 1 and (flo < 0) != (ft < 0):
|
||||
hi = t
|
||||
for _ in range(BISECT):
|
||||
mid = (lo + hi) / 2
|
||||
fm = f(mid)
|
||||
if not _finite(fm):
|
||||
return None
|
||||
if (fm < 0) == (flo < 0):
|
||||
lo = mid
|
||||
flo = fm
|
||||
else:
|
||||
hi = mid
|
||||
return at((lo + hi) / 2)
|
||||
lo = t
|
||||
flo = ft
|
||||
i += 1
|
||||
return None
|
||||
|
||||
|
||||
def compass_candidates(
|
||||
start: Sequence[float], length_m: float, slope_pct: float, height_at: HeightAt
|
||||
) -> list[Vertex]:
|
||||
"""둘 다(양각기) — 반지름 `length_m` 원 위에서 |기울기| = 목표인 후보들
|
||||
(방위 표본 · 이분 · 동쪽부터 반시계)."""
|
||||
z0 = height_at(start[0], start[1])
|
||||
if not length_m > EPS or not _finite(z0):
|
||||
return []
|
||||
rise = (abs(slope_pct) / 100) * length_m
|
||||
step = (2 * math.pi) / COMPASS_SAMPLES
|
||||
|
||||
def at(angle: float) -> Vertex:
|
||||
return (start[0] + math.cos(angle) * length_m, start[1] + math.sin(angle) * length_m)
|
||||
|
||||
def h(angle: float) -> float:
|
||||
p = at(angle)
|
||||
return abs(height_at(p[0], p[1]) - z0) - rise
|
||||
|
||||
values = [h(k * step) for k in range(COMPASS_SAMPLES)]
|
||||
out: list[Vertex] = []
|
||||
for k in range(COMPASS_SAMPLES):
|
||||
hk = values[k]
|
||||
hn = values[(k + 1) % COMPASS_SAMPLES]
|
||||
if not _finite(hk) or not _finite(hn):
|
||||
continue
|
||||
if hk == 0:
|
||||
out.append(at(k * step))
|
||||
continue
|
||||
if hn == 0 or (hk < 0) == (hn < 0):
|
||||
continue
|
||||
lo = k * step
|
||||
hi = lo + step
|
||||
flo = hk
|
||||
ok = True
|
||||
for _ in range(BISECT):
|
||||
mid = (lo + hi) / 2
|
||||
fm = h(mid)
|
||||
if not _finite(fm):
|
||||
ok = False
|
||||
break
|
||||
if (fm < 0) == (flo < 0):
|
||||
lo = mid
|
||||
flo = fm
|
||||
else:
|
||||
hi = mid
|
||||
if ok:
|
||||
out.append(at((lo + hi) / 2))
|
||||
return out
|
||||
|
||||
|
||||
def compass_point(
|
||||
start: Sequence[float],
|
||||
cursor: Sequence[float],
|
||||
length_m: float,
|
||||
slope_pct: float,
|
||||
height_at: HeightAt,
|
||||
) -> Vertex | None:
|
||||
"""둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 None."""
|
||||
best: Vertex | None = None
|
||||
best_d = math.inf
|
||||
for p in compass_candidates(start, length_m, slope_pct, height_at):
|
||||
d = segment_length(p, cursor)
|
||||
if d < best_d:
|
||||
best = p
|
||||
best_d = d
|
||||
return best
|
||||
|
||||
|
||||
def line_length(line: Sequence[Sequence[float]]) -> float:
|
||||
"""선 길이(m) — 앞에서부터 더함."""
|
||||
total = 0.0
|
||||
for i in range(1, len(line)):
|
||||
total += segment_length(line[i - 1], line[i])
|
||||
return total
|
||||
|
||||
|
||||
def point_at_distance(line: Sequence[Sequence[float]], m: float) -> Vertex:
|
||||
"""선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상."""
|
||||
if m <= 0:
|
||||
return (line[0][0], line[0][1])
|
||||
acc = 0.0
|
||||
for i in range(1, len(line)):
|
||||
a = line[i - 1]
|
||||
b = line[i]
|
||||
seg = segment_length(a, b)
|
||||
if seg > 0 and acc + seg >= m:
|
||||
t = (m - acc) / seg
|
||||
return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
|
||||
acc += seg
|
||||
return (line[-1][0], line[-1][1])
|
||||
|
||||
|
||||
def distance_along(line: Sequence[Sequence[float]], point: Sequence[float]) -> float:
|
||||
"""점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간)."""
|
||||
best = 0.0
|
||||
best_d = math.inf
|
||||
acc = 0.0
|
||||
for i in range(1, len(line)):
|
||||
a = line[i - 1]
|
||||
b = line[i]
|
||||
seg = segment_length(a, b)
|
||||
t = 0.0
|
||||
if seg > 0:
|
||||
t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / (
|
||||
seg * seg
|
||||
)
|
||||
t = min(1.0, max(0.0, t))
|
||||
p = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
|
||||
d = segment_length(p, point)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = acc + seg * t
|
||||
acc += seg
|
||||
return best
|
||||
|
||||
|
||||
def slice_line(line: Sequence[Sequence[float]], from_m: float, to_m: float) -> list[Vertex]:
|
||||
"""선 조각 — `from_m`~`to_m` 사이(끝점 + 그 사이 꺾임점)."""
|
||||
lo = min(from_m, to_m)
|
||||
hi = max(from_m, to_m)
|
||||
out = [point_at_distance(line, lo)]
|
||||
acc = 0.0
|
||||
for i in range(1, len(line) - 1):
|
||||
acc += segment_length(line[i - 1], line[i])
|
||||
if lo + EPS < acc < hi - EPS:
|
||||
out.append((line[i][0], line[i][1]))
|
||||
out.append(point_at_distance(line, hi))
|
||||
return out
|
||||
|
||||
|
||||
def station_chainages(length_m: float, interval_m: float = 20) -> list[float]:
|
||||
"""측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터)."""
|
||||
out: list[float] = [0]
|
||||
k = 1
|
||||
while k * interval_m < length_m - 1e-6:
|
||||
out.append(k * interval_m)
|
||||
k += 1
|
||||
if length_m > 1e-6:
|
||||
out.append(length_m)
|
||||
return out
|
||||
@@ -26,7 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_MapSheet import (
|
||||
sheet5k_to_bounds,
|
||||
sheets_for_points,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import parse_and_merge_sheets
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import PARSER_VERSION, parse_and_merge_sheets
|
||||
from B04_PreProcess.B04_PreProcess_Engine_SheetStore import (
|
||||
ensure_sheets,
|
||||
get_project_map_sheets_dir,
|
||||
@@ -114,7 +114,7 @@ def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[st
|
||||
key_path = processed / "sheet_merge.key.json"
|
||||
old = (_read_json(key_path) or {}).get("sheets") or []
|
||||
sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)})
|
||||
key = {"sheets": sheets}
|
||||
key = {"sheets": sheets, "parser": PARSER_VERSION}
|
||||
if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))):
|
||||
parse_and_merge_sheets(store, sheets, processed)
|
||||
atomic_write_json(key_path, key)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Draw.ts
|
||||
* D 예정노선 수동 직선 그리기 셈(PLAN 562-1 · 요구 389 12 · 13) — 구간 길이 · 원지반 기울기 · 길이 고정 ·
|
||||
* 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점. 높이는 인자 `heightAt(x, y)`(NaN = 지형 밖).
|
||||
* DOM · fetch · sessionStorage 없음.
|
||||
*
|
||||
* ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Draw.py` 같은 이름(snake). 한쪽을 고치면 다른 쪽도 고치고 거울 시험
|
||||
* `resources/tester/test_562_1_plan_draw.py`. 화면은 그리는 즉시 이 짝으로 · [저장]은 서버가 다시 셈.
|
||||
* ========================================================================== */
|
||||
|
||||
export type Vertex = [number, number];
|
||||
export type HeightAt = (x: number, y: number) => number;
|
||||
|
||||
/** 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수. */
|
||||
const SLOPE_STEP_M = 1;
|
||||
const BISECT = 40;
|
||||
const COMPASS_SAMPLES = 360;
|
||||
const EPS = 1e-9;
|
||||
|
||||
/** 수평 길이(m). */
|
||||
export function segmentLength(a: Vertex, b: Vertex): number {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/** 원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · null = 지형 밖 · 길이 0. */
|
||||
export function groundSlopePct(a: Vertex, b: Vertex, heightAt: HeightAt): number | null {
|
||||
const length = segmentLength(a, b);
|
||||
if (length < EPS) return null;
|
||||
const za = heightAt(a[0], a[1]);
|
||||
const zb = heightAt(b[0], b[1]);
|
||||
if (!Number.isFinite(za) || !Number.isFinite(zb)) return null;
|
||||
return ((zb - za) / length) * 100;
|
||||
}
|
||||
|
||||
/** 길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 null. */
|
||||
export function fixedLengthPoint(from: Vertex, cursor: Vertex, lengthM: number): Vertex | null {
|
||||
const d = segmentLength(from, cursor);
|
||||
if (d < EPS) return null;
|
||||
return [
|
||||
from[0] + ((cursor[0] - from[0]) / d) * lengthM,
|
||||
from[1] + ((cursor[1] - from[1]) / d) * lengthM,
|
||||
];
|
||||
}
|
||||
|
||||
/** 기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) ·
|
||||
* `maxM` 안에 없거나 지형 밖에 닿으면 null. */
|
||||
export function fixedSlopePoint(
|
||||
from: Vertex,
|
||||
cursor: Vertex,
|
||||
slopePct: number,
|
||||
heightAt: HeightAt,
|
||||
maxM: number = 1000,
|
||||
): Vertex | null {
|
||||
const d = segmentLength(from, cursor);
|
||||
const z0 = heightAt(from[0], from[1]);
|
||||
if (d < EPS || !Number.isFinite(z0)) return null;
|
||||
const ux = (cursor[0] - from[0]) / d;
|
||||
const uy = (cursor[1] - from[1]) / d;
|
||||
const grade = Math.abs(slopePct) / 100;
|
||||
const at = (t: number): Vertex => [from[0] + ux * t, from[1] + uy * t];
|
||||
const f = (t: number): number => {
|
||||
const p = at(t);
|
||||
return Math.abs(heightAt(p[0], p[1]) - z0) - grade * t;
|
||||
};
|
||||
let lo = 0;
|
||||
let flo = NaN;
|
||||
for (let i = 1; i * SLOPE_STEP_M <= maxM; i += 1) {
|
||||
const t = i * SLOPE_STEP_M;
|
||||
const ft = f(t);
|
||||
if (!Number.isFinite(ft)) return null;
|
||||
if (ft === 0) return at(t);
|
||||
if (i > 1 && flo < 0 !== ft < 0) {
|
||||
let hi = t;
|
||||
for (let k = 0; k < BISECT; k += 1) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const fm = f(mid);
|
||||
if (!Number.isFinite(fm)) return null;
|
||||
if (fm < 0 === flo < 0) {
|
||||
lo = mid;
|
||||
flo = fm;
|
||||
} else hi = mid;
|
||||
}
|
||||
return at((lo + hi) / 2);
|
||||
}
|
||||
lo = t;
|
||||
flo = ft;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 둘 다(양각기) — 반지름 `lengthM` 원 위에서 |기울기| = 목표인 후보들(방위 표본 · 이분 · 동쪽부터
|
||||
* 반시계). */
|
||||
export function compassCandidates(
|
||||
from: Vertex,
|
||||
lengthM: number,
|
||||
slopePct: number,
|
||||
heightAt: HeightAt,
|
||||
): Vertex[] {
|
||||
const z0 = heightAt(from[0], from[1]);
|
||||
if (!(lengthM > EPS) || !Number.isFinite(z0)) return [];
|
||||
const rise = (Math.abs(slopePct) / 100) * lengthM;
|
||||
const step = (2 * Math.PI) / COMPASS_SAMPLES;
|
||||
const at = (angle: number): Vertex => [
|
||||
from[0] + Math.cos(angle) * lengthM,
|
||||
from[1] + Math.sin(angle) * lengthM,
|
||||
];
|
||||
const h = (angle: number): number => {
|
||||
const p = at(angle);
|
||||
return Math.abs(heightAt(p[0], p[1]) - z0) - rise;
|
||||
};
|
||||
const values: number[] = [];
|
||||
for (let k = 0; k < COMPASS_SAMPLES; k += 1) values.push(h(k * step));
|
||||
const out: Vertex[] = [];
|
||||
for (let k = 0; k < COMPASS_SAMPLES; k += 1) {
|
||||
const hk = values[k];
|
||||
const hn = values[(k + 1) % COMPASS_SAMPLES];
|
||||
if (!Number.isFinite(hk) || !Number.isFinite(hn)) continue;
|
||||
if (hk === 0) {
|
||||
out.push(at(k * step));
|
||||
continue;
|
||||
}
|
||||
if (hn === 0 || hk < 0 === hn < 0) continue;
|
||||
let lo = k * step;
|
||||
let hi = lo + step;
|
||||
let flo = hk;
|
||||
let ok = true;
|
||||
for (let i = 0; i < BISECT; i += 1) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const fm = h(mid);
|
||||
if (!Number.isFinite(fm)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (fm < 0 === flo < 0) {
|
||||
lo = mid;
|
||||
flo = fm;
|
||||
} else hi = mid;
|
||||
}
|
||||
if (ok) out.push(at((lo + hi) / 2));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 null. */
|
||||
export function compassPoint(
|
||||
from: Vertex,
|
||||
cursor: Vertex,
|
||||
lengthM: number,
|
||||
slopePct: number,
|
||||
heightAt: HeightAt,
|
||||
): Vertex | null {
|
||||
let best: Vertex | null = null;
|
||||
let bestD = Infinity;
|
||||
for (const p of compassCandidates(from, lengthM, slopePct, heightAt)) {
|
||||
const d = segmentLength(p, cursor);
|
||||
if (d < bestD) {
|
||||
best = p;
|
||||
bestD = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 선 길이(m) — 앞에서부터 더함. */
|
||||
export function lineLength(line: ReadonlyArray<Vertex>): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < line.length; i += 1) total += segmentLength(line[i - 1], line[i]);
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상. */
|
||||
export function pointAtDistance(line: ReadonlyArray<Vertex>, m: number): Vertex {
|
||||
if (m <= 0) return [line[0][0], line[0][1]];
|
||||
let acc = 0;
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
const a = line[i - 1];
|
||||
const b = line[i];
|
||||
const seg = segmentLength(a, b);
|
||||
if (seg > 0 && acc + seg >= m) {
|
||||
const t = (m - acc) / seg;
|
||||
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
}
|
||||
acc += seg;
|
||||
}
|
||||
const last = line[line.length - 1];
|
||||
return [last[0], last[1]];
|
||||
}
|
||||
|
||||
/** 점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간). */
|
||||
export function distanceAlong(line: ReadonlyArray<Vertex>, point: Vertex): number {
|
||||
let best = 0;
|
||||
let bestD = Infinity;
|
||||
let acc = 0;
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
const a = line[i - 1];
|
||||
const b = line[i];
|
||||
const seg = segmentLength(a, b);
|
||||
let t = 0;
|
||||
if (seg > 0) {
|
||||
t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / (seg * seg);
|
||||
t = Math.min(1, Math.max(0, t));
|
||||
}
|
||||
const p: Vertex = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||
const d = segmentLength(p, point);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = acc + seg * t;
|
||||
}
|
||||
acc += seg;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 선 조각 — `fromM`~`toM` 사이(끝점 + 그 사이 꺾임점). */
|
||||
export function sliceLine(line: ReadonlyArray<Vertex>, fromM: number, toM: number): Vertex[] {
|
||||
const lo = Math.min(fromM, toM);
|
||||
const hi = Math.max(fromM, toM);
|
||||
const out: Vertex[] = [pointAtDistance(line, lo)];
|
||||
let acc = 0;
|
||||
for (let i = 1; i < line.length - 1; i += 1) {
|
||||
acc += segmentLength(line[i - 1], line[i]);
|
||||
if (acc > lo + EPS && acc < hi - EPS) out.push([line[i][0], line[i][1]]);
|
||||
}
|
||||
out.push(pointAtDistance(line, hi));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터). */
|
||||
export function stationChainages(lengthM: number, intervalM: number = 20): number[] {
|
||||
const out = [0];
|
||||
for (let k = 1; k * intervalM < lengthM - 1e-6; k += 1) out.push(k * intervalM);
|
||||
if (lengthM > 1e-6) out.push(lengthM);
|
||||
return out;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { createIconButton } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { e03Table } from "./E03_StabSection_UI_Table";
|
||||
import {
|
||||
listRows,
|
||||
@@ -29,7 +30,9 @@ export interface ListDraw {
|
||||
sort: SortMode;
|
||||
ngOnly: boolean;
|
||||
active: string | null;
|
||||
/** 사용자 켬/끔 — 저장 전 캐시 포함 · 없으면 서버 값 */
|
||||
/** 측점 간격(m) — 측점 글 「No.+거리」 의 분모 */
|
||||
interval: number;
|
||||
/** 사용자 켬/끔 — 서버 값(별 누름 = 바로 저장) */
|
||||
representative: (row: StationRow) => { on: boolean; user: boolean | null };
|
||||
editable: boolean;
|
||||
}
|
||||
@@ -81,7 +84,7 @@ export function buildList(
|
||||
);
|
||||
verdict.classList.add("e03-verdict", `e03-verdict--${v}`);
|
||||
const station = el("span", {
|
||||
text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key,
|
||||
text: stationLabel(row.chainage_m, draw.interval, 1),
|
||||
children: row.walls
|
||||
? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })]
|
||||
: [],
|
||||
|
||||
@@ -24,7 +24,8 @@ import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import { mountGroupStep } from "../A00_Common/group_workflow";
|
||||
import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing";
|
||||
import { buildList } from "./E03_StabSection_UI_List";
|
||||
import { buildPlan } from "./E03_StabSection_UI_Plan";
|
||||
import { buildPlan, type PlanView } from "./E03_StabSection_UI_Plan";
|
||||
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
|
||||
import { card, check, emptyBox, muted } from "./E03_StabSection_UI_Parts";
|
||||
import { buildResult } from "./E03_StabSection_UI_Result";
|
||||
import {
|
||||
@@ -84,7 +85,6 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return body;
|
||||
}
|
||||
|
||||
const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`;
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export function renderE03StabSection(root: HTMLElement): void {
|
||||
@@ -111,9 +111,9 @@ export function renderE03StabSection(root: HTMLElement): void {
|
||||
const slopeRecs = new Map<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
|
||||
const wallRecs = new Map<string, WallRecord>();
|
||||
const supportRecs = new Map<string, SupportRecord>();
|
||||
let repCache: Record<string, boolean | null> = JSON.parse(
|
||||
sessionStorage.getItem(repKey(pid)) ?? "{}",
|
||||
);
|
||||
let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390)
|
||||
const planView: PlanView = {};
|
||||
let repSaving: Promise<void> = Promise.resolve(); // 별 누름마다 차례로 저장
|
||||
|
||||
// ---- 좌측 ----
|
||||
const condField = createSelectField({
|
||||
@@ -158,15 +158,10 @@ export function renderE03StabSection(root: HTMLElement): void {
|
||||
variant: "ghost",
|
||||
onClick: () => void show3d(),
|
||||
});
|
||||
const repButton = createButton({
|
||||
label: t("E03_RepSave"),
|
||||
variant: "ghost",
|
||||
onClick: () => void saveRep(),
|
||||
});
|
||||
const controls = el("div", { className: "e03-panel__row" });
|
||||
controls.append(condField.root, sortField.root);
|
||||
const actions = el("div", { className: "ui-sidebar-actions" });
|
||||
actions.append(repButton, view3dButton, analyzeButton);
|
||||
actions.append(view3dButton, analyzeButton);
|
||||
panel.append(controls, ngBox, status, listHost, actions);
|
||||
|
||||
// ---- 본문 ----
|
||||
@@ -187,11 +182,8 @@ export function renderE03StabSection(root: HTMLElement): void {
|
||||
|
||||
const repOf = (row: StationRow): { on: boolean; user: boolean | null } => {
|
||||
const server = row.representative ?? { auto: [], user: null, on: false };
|
||||
if (!(row.key in repCache)) return { on: server.on, user: server.user };
|
||||
const user = repCache[row.key];
|
||||
return { on: user === null ? server.auto.length > 0 : user, user };
|
||||
return { on: server.on, user: server.user };
|
||||
};
|
||||
const repDirty = (): boolean => Object.keys(repCache).length > 0;
|
||||
|
||||
const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active);
|
||||
|
||||
@@ -217,10 +209,8 @@ export function renderE03StabSection(root: HTMLElement): void {
|
||||
if (!result.fresh) lines.push(t("E03_Stale"));
|
||||
}
|
||||
if (result && !result.editable) lines.push(t("E03_ReadOnly"));
|
||||
if (repDirty()) lines.push(t("E03_RepUnsaved"));
|
||||
status.textContent = lines.join("\n");
|
||||
analyzeButton.disabled = !result?.editable;
|
||||
repButton.disabled = !result?.editable || !repDirty();
|
||||
view3dButton.disabled = result?.status !== "ok";
|
||||
};
|
||||
|
||||
@@ -235,22 +225,12 @@ export function renderE03StabSection(root: HTMLElement): void {
|
||||
sort,
|
||||
ngOnly,
|
||||
active,
|
||||
interval,
|
||||
representative: repOf,
|
||||
editable: result.editable,
|
||||
},
|
||||
(key) => void select(key),
|
||||
(key) => {
|
||||
const row = result!.stations.find((r) => r.key === key)!;
|
||||
const now = repOf(row).on;
|
||||
const auto = (row.representative?.auto.length ?? 0) > 0;
|
||||
// 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌
|
||||
const next = !now === auto ? null : !now;
|
||||
if (next === (row.representative?.user ?? null)) delete repCache[key];
|
||||
else repCache[key] = next;
|
||||
sessionStorage.setItem(repKey(pid), JSON.stringify(repCache));
|
||||
paintList();
|
||||
paintStatus();
|
||||
},
|
||||
(key) => void toggleRep(key),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -286,7 +266,7 @@ export function renderE03StabSection(root: HTMLElement): void {
|
||||
});
|
||||
const title = el("h3", {
|
||||
className: "e03-card__title",
|
||||
text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
|
||||
text: `${stationLabel(view.chainage_m, interval, 1)} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
|
||||
});
|
||||
const flags = el("div", { className: "e03-panel__row" });
|
||||
flags.append(
|
||||
@@ -399,7 +379,16 @@ export function renderE03StabSection(root: HTMLElement): void {
|
||||
|
||||
const paintPlan = (): void => {
|
||||
planHost.replaceChildren(
|
||||
result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"),
|
||||
result
|
||||
? buildPlan(
|
||||
result.stations,
|
||||
condition,
|
||||
active,
|
||||
interval,
|
||||
planView,
|
||||
(key) => void select(key),
|
||||
)
|
||||
: el("div"),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -438,7 +427,10 @@ export function renderE03StabSection(root: HTMLElement): void {
|
||||
condition,
|
||||
);
|
||||
const keys = result.stations.map((r) => r.key);
|
||||
repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k)));
|
||||
interval = inferStationInterval(
|
||||
result.stations.slice().sort((a, b) => a.chainage_m - b.chainage_m),
|
||||
);
|
||||
delete planView.zoom; // 다시 읽음 = 띠 원배율
|
||||
if (!active || !keys.includes(active)) {
|
||||
// 처음 = 그 조건 가장 낮은 안전율 측점
|
||||
const worst = result.stations
|
||||
@@ -488,20 +480,50 @@ export function renderE03StabSection(root: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRep(): Promise<void> {
|
||||
try {
|
||||
/** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */
|
||||
function toggleRep(key: string): Promise<void> {
|
||||
repSaving = repSaving.then(async () => {
|
||||
const row = result?.stations.find((r) => r.key === key);
|
||||
if (!row) return;
|
||||
const server = row.representative ?? { auto: [], user: null, on: false };
|
||||
const auto = server.auto.length > 0;
|
||||
const next = !server.on === auto ? null : !server.on;
|
||||
const overrides: Record<string, boolean | null> = {};
|
||||
for (const row of result?.stations ?? []) {
|
||||
const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null);
|
||||
if (user !== null) overrides[row.key] = user;
|
||||
for (const r of result?.stations ?? []) {
|
||||
const user = r.key === key ? next : (r.representative?.user ?? null);
|
||||
if (user !== null) overrides[r.key] = user;
|
||||
}
|
||||
await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) });
|
||||
repCache = {};
|
||||
sessionStorage.removeItem(repKey(pid));
|
||||
showToast(t("E03_RepSaved"), "success");
|
||||
await load();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
try {
|
||||
await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) });
|
||||
row.representative = { ...server, user: next, on: next === null ? auto : next };
|
||||
paintList();
|
||||
void refreshReport();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
|
||||
}
|
||||
});
|
||||
return repSaving;
|
||||
}
|
||||
|
||||
/** 보고서 갱신 — E05 [보고서 갱신] 과 같은 길(GET 이 작업본 없으면 처음 만듦 · 뒤에서) */
|
||||
let reportRun = 0;
|
||||
async function refreshReport(): Promise<void> {
|
||||
const run = ++reportRun;
|
||||
await sleep(800); // 별을 잇달아 누르면 마지막 한 번만
|
||||
if (run !== reportRun) return;
|
||||
try {
|
||||
const state = await api<{ book: unknown; rev: string | null }>(
|
||||
`/projects/${pid}/stab/report`,
|
||||
);
|
||||
if (!state.book || run !== reportRun) return;
|
||||
const body = await api<{ overwritten?: string[] }>(`/projects/${pid}/stab/report/refresh`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rev: state.rev }),
|
||||
});
|
||||
const over = body.overwritten ?? [];
|
||||
if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
|
||||
} catch {
|
||||
/* 보고서는 E05 를 열 때 stale 로 알림 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
* E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이
|
||||
* 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점.
|
||||
* 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`.
|
||||
* 확대 · 이동 = 공용 `attachSvgZoomPan`(휠 · 가운데 끌기 · 두 번 누름 원배율 · 요구 390) · 측점 글 솎음
|
||||
* = logic `labelStride` · `planLabelKeys` · 배율 · 폭은 바깥 `PlanView` 가 쥠(다시 지어도 살아남음).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { t, type LocaleKey } from "@ui/ui_template_locale";
|
||||
import {
|
||||
bandSegments,
|
||||
labelStride,
|
||||
planFit,
|
||||
planLabelKeys,
|
||||
verdictOf,
|
||||
type Condition,
|
||||
type Point,
|
||||
@@ -21,11 +27,20 @@ import { svgEl } from "./E03_StabSection_UI_Section";
|
||||
|
||||
const H = 96;
|
||||
const PAD = 10;
|
||||
const LABEL_GAP = 64; // 측점 글 사이 최소 화면 간격(px)
|
||||
|
||||
/** 띠 배율 · 그때 폭 — 폭이 바뀌면 원배율로 */
|
||||
export interface PlanView {
|
||||
zoom?: ZoomPanState;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function buildPlan(
|
||||
rows: StationRow[],
|
||||
condition: Condition,
|
||||
active: string | null,
|
||||
interval: number,
|
||||
state: PlanView,
|
||||
onSelect: (key: string) => void,
|
||||
): HTMLElement {
|
||||
const box = card("e03-plan");
|
||||
@@ -49,47 +64,110 @@ export function buildPlan(
|
||||
|
||||
const host = el("div");
|
||||
box.append(host);
|
||||
const ro = new ResizeObserver(() => draw());
|
||||
const draw = (): void => {
|
||||
if (!host.isConnected) return void ro.disconnect(); // 다시 지은 띠 — 옛 것은 손 뗌
|
||||
const W = Math.max(host.clientWidth, 300);
|
||||
const segs = bandSegments(rows, condition);
|
||||
const all: Point[] = segs.flatMap((s) => s.points);
|
||||
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" });
|
||||
if (all.length) {
|
||||
const fit = planFit(all, W, H, PAD);
|
||||
const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
|
||||
for (const seg of segs) {
|
||||
svg.append(
|
||||
svgEl("polyline", {
|
||||
points: seg.points.map((p) => scr(p).join(",")).join(" "),
|
||||
class: `e03-plan__seg e03-plan__seg--${seg.verdict}`,
|
||||
"data-verdict": seg.verdict,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const centers = rows.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null);
|
||||
const picked = centers.find((r) => r.key === active);
|
||||
if (picked) {
|
||||
const [x, y] = scr([picked.center![0]!, picked.center![1]!]);
|
||||
svg.append(
|
||||
svgEl("circle", { cx: x, cy: y, r: 6, class: "e03-plan__pick", "data-key": picked.key }),
|
||||
);
|
||||
}
|
||||
svg.addEventListener("click", (ev) => {
|
||||
const box2 = svg.getBoundingClientRect();
|
||||
const px = ((ev.clientX - box2.left) / box2.width) * W;
|
||||
const py = ((ev.clientY - box2.top) / box2.height) * H;
|
||||
let best: StationRow | null = null;
|
||||
let dist = Infinity;
|
||||
for (const r of centers) {
|
||||
const [x, y] = scr([r.center![0]!, r.center![1]!]);
|
||||
const d = Math.hypot(x - px, y - py);
|
||||
if (d < dist) [best, dist] = [r, d];
|
||||
}
|
||||
if (best && dist < 30) onSelect(best.key);
|
||||
});
|
||||
}
|
||||
host.replaceChildren(svg);
|
||||
if (!all.length) return;
|
||||
const fit = planFit(all, W, H, PAD);
|
||||
const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
|
||||
const layer = svgEl("g", { "data-role": "plan-layer" }) as SVGGElement;
|
||||
for (const seg of segs) {
|
||||
layer.append(
|
||||
svgEl("polyline", {
|
||||
points: seg.points.map((p) => scr(p).join(",")).join(" "),
|
||||
class: `e03-plan__seg e03-plan__seg--${seg.verdict}`,
|
||||
"data-verdict": seg.verdict,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const centers = rows
|
||||
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
const at = (r: StationRow): Point => scr([r.center![0]!, r.center![1]!]);
|
||||
// 측점 간격 하나의 화면 길이(원배율) — 중심 이은 길이 ÷ 누가 길이 × 간격
|
||||
const span =
|
||||
centers.length > 1 ? centers[centers.length - 1].chainage_m - centers[0].chainage_m : 0;
|
||||
let len = 0;
|
||||
for (let i = 1; i < centers.length; i += 1) {
|
||||
const [a, b] = [at(centers[i - 1]), at(centers[i])];
|
||||
len += Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
}
|
||||
const intervalPx = span > 0 ? (len / span) * interval : 0;
|
||||
const maxScale = Math.min(500, Math.max(8, Math.ceil(240 / Math.max(intervalPx, 1e-3))));
|
||||
const picked = centers.find((r) => r.key === active);
|
||||
const pick = picked
|
||||
? svgEl("circle", {
|
||||
cx: at(picked)[0],
|
||||
cy: at(picked)[1],
|
||||
r: 6,
|
||||
class: "e03-plan__pick",
|
||||
"data-key": picked.key,
|
||||
})
|
||||
: null;
|
||||
const labels = svgEl("g", { class: "e03-plan__labels" }) as SVGGElement;
|
||||
layer.append(labels, ...(pick ? [pick] : []));
|
||||
svg.append(layer);
|
||||
let stride = 0;
|
||||
const apply = (s: ZoomPanState): void => {
|
||||
state.zoom = s;
|
||||
labels.style.setProperty("--e03-label-k", String(1 / s.scale)); // 글 · 표시는 확대 안 함
|
||||
pick?.setAttribute("r", String(6 / s.scale));
|
||||
const next = intervalPx > 0 ? labelStride(intervalPx * s.scale, LABEL_GAP) : 100;
|
||||
if (next === stride) return;
|
||||
stride = next;
|
||||
const keep = new Set(planLabelKeys(centers, interval, stride, active));
|
||||
labels.replaceChildren(
|
||||
...centers
|
||||
.filter((r) => keep.has(r.key))
|
||||
.map((r) =>
|
||||
svgEl(
|
||||
"text",
|
||||
{
|
||||
x: at(r)[0],
|
||||
y: at(r)[1],
|
||||
dy: "-0.9em",
|
||||
class: "e03-label e03-plan__label",
|
||||
"data-key": r.key,
|
||||
},
|
||||
stationLabel(r.chainage_m, interval, 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
const initial = state.width === W ? state.zoom : undefined;
|
||||
state.width = W;
|
||||
attachSvgZoomPan({
|
||||
svg,
|
||||
layer,
|
||||
widthPx: W,
|
||||
heightPx: H,
|
||||
pad: { left: 0, right: 0, top: 0, bottom: 0 },
|
||||
wheelZoom: true,
|
||||
maxScale,
|
||||
initial,
|
||||
onChange: apply,
|
||||
});
|
||||
apply(initial ?? { scale: 1, tx: 0, ty: 0 });
|
||||
svg.addEventListener("click", (ev) => {
|
||||
const box2 = svg.getBoundingClientRect();
|
||||
const px = ((ev.clientX - box2.left) / box2.width) * W;
|
||||
const py = ((ev.clientY - box2.top) / box2.height) * H;
|
||||
const s = state.zoom ?? { scale: 1, tx: 0, ty: 0 };
|
||||
let best: StationRow | null = null;
|
||||
let dist = Infinity;
|
||||
for (const r of centers) {
|
||||
const [x, y] = at(r);
|
||||
const d = Math.hypot(s.scale * x + s.tx - px, s.scale * y + s.ty - py);
|
||||
if (d < dist) [best, dist] = [r, d];
|
||||
}
|
||||
if (best && dist < 30) onSelect(best.key);
|
||||
});
|
||||
};
|
||||
new ResizeObserver(draw).observe(host);
|
||||
ro.observe(host);
|
||||
return box;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
/* ---- 틀 ---- */
|
||||
.e03-page {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
gap: var(--spacing-8);
|
||||
height: calc(100dvh - var(--app-header-h));
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
@@ -207,6 +207,7 @@
|
||||
.e03-plan__seg {
|
||||
fill: none;
|
||||
stroke-width: 7;
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-linecap: butt;
|
||||
stroke-linejoin: round;
|
||||
cursor: pointer;
|
||||
@@ -228,6 +229,12 @@
|
||||
fill: var(--e03-plot);
|
||||
stroke: var(--e03-critical);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.e03-plan__label {
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.e03-legend {
|
||||
|
||||
@@ -190,6 +190,31 @@ export function planFit(
|
||||
return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 };
|
||||
}
|
||||
|
||||
/** 평면 띠 측점 글 솎음 걸음 — 측점 간격 화면 길이(px) × 걸음이 minPx 넘는 가장 작은 것 */
|
||||
export const PLAN_LABEL_STRIDES = [1, 2, 5, 10, 25, 50, 100] as const;
|
||||
|
||||
export function labelStride(intervalPx: number, minPx: number): number {
|
||||
return PLAN_LABEL_STRIDES.find((s) => s * intervalPx >= minPx) ?? 100;
|
||||
}
|
||||
|
||||
/** 측점 글 붙일 열쇠 — 본 측점(간격 배수) 중 걸음 배수 · 걸음 1 이면 보조 측점도 · 고른 측점은 늘 */
|
||||
export function planLabelKeys(
|
||||
rows: StationRow[],
|
||||
interval: number,
|
||||
stride: number,
|
||||
active: string | null,
|
||||
): string[] {
|
||||
const step = interval > 0 ? interval : 1;
|
||||
return rows
|
||||
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
|
||||
.filter((r) => {
|
||||
if (r.key === active || stride === 1) return true;
|
||||
const n = Math.round(r.chainage_m / step);
|
||||
return Math.abs(r.chainage_m - n * step) < 0.05 && n % stride === 0;
|
||||
})
|
||||
.map((r) => r.key);
|
||||
}
|
||||
|
||||
/* ---- 안전율 색 ------------------------------------------------------------ */
|
||||
|
||||
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
|
||||
|
||||
@@ -411,6 +411,9 @@ process.stdout.write(JSON.stringify({
|
||||
stress: m.sliceStress([input.slice]),
|
||||
ticks: m.niceTicks(-3, 47, 5),
|
||||
zAt: [m.zAt([[0, 0], [10, 10]], 2.5), m.zAt([[10, 10], [0, 0]], -5)],
|
||||
stride: [m.labelStride(100, 64), m.labelStride(40, 64), m.labelStride(10, 64), m.labelStride(0.1, 64)],
|
||||
labels: [1, 2, 5].map((k) => m.planLabelKeys(input.rows, 20, k, null)),
|
||||
labelsActive: m.planLabelKeys(input.rows, 20, 5, "e"),
|
||||
}));
|
||||
"""
|
||||
|
||||
@@ -541,6 +544,10 @@ def test_logic_TS():
|
||||
)
|
||||
assert out["ticks"][0] <= -3 and out["ticks"][-1] >= 47 and 0 in out["ticks"]
|
||||
assert out["zAt"] == [2.5, 0]
|
||||
# 평면 띠 측점 글 솎음 — 간격 px × 걸음 ≥ 64 · 본 측점 중 걸음 배수 · 고른 측점은 늘
|
||||
assert out["stride"] == [1, 2, 10, 100]
|
||||
assert out["labels"] == [["a", "b", "c", "d", "e"], ["a", "c", "e"], ["a"]]
|
||||
assert out["labelsActive"] == ["a", "e"]
|
||||
|
||||
|
||||
def test_엔진_지문은_코드_내용():
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""PLAN 561-1 — `.cpg` 없는 도엽 dbf = cp949 로 읽음 · 병합 열쇠에 파서 판 번호.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_561_1_sheet_encoding.py -q
|
||||
|
||||
`.cpg` 없는 dbf 를 그냥 읽으면 latin-1 → 칸 이름 「등고수치」가 깨져 등고 높이가 빔
|
||||
(D 프로젝트 4250c7bc 「등고 라인 94단을 격자에 굽어 0셀」).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import geopandas as gpd
|
||||
from shapely.geometry import LineString
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import B04_PreProcess.B04_PreProcess_Engine_SheetParser as parser # noqa: E402
|
||||
|
||||
BASE = "N3L_F0010000"
|
||||
|
||||
|
||||
def _sheet_zip(tmp_path: Path, with_cpg: bool) -> Path:
|
||||
"""등고선 두 줄짜리 도엽 zip — dbf 는 cp949 · `.cpg` 는 고름."""
|
||||
shp_dir = tmp_path / ("cpg" if with_cpg else "nocpg")
|
||||
shp_dir.mkdir()
|
||||
gdf = gpd.GeoDataFrame(
|
||||
{"등고수치": [100.0, 105.0], "구분": ["주곡선", "주곡선"]},
|
||||
geometry=[LineString([(0, 0), (10, 0)]), LineString([(0, 5), (10, 5)])],
|
||||
crs="EPSG:5186",
|
||||
)
|
||||
gdf.to_file(shp_dir / f"{BASE}.shp", encoding="cp949")
|
||||
cpg = shp_dir / f"{BASE}.cpg"
|
||||
if with_cpg:
|
||||
cpg.write_text("CP949", encoding="ascii")
|
||||
else:
|
||||
cpg.unlink(missing_ok=True)
|
||||
zip_path = tmp_path / f"{shp_dir.name}.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for f in shp_dir.iterdir():
|
||||
zf.write(f, f.name)
|
||||
return zip_path
|
||||
|
||||
|
||||
def _read(zip_path: Path) -> gpd.GeoDataFrame:
|
||||
gdf = parser._read_layer_from_zip(zip_path, "N3L", "F0010000", "EPSG:5186")
|
||||
assert gdf is not None
|
||||
return gdf
|
||||
|
||||
|
||||
def test_no_cpg_reads_cp949(tmp_path):
|
||||
gdf = _read(_sheet_zip(tmp_path, with_cpg=False))
|
||||
assert "등고수치" in gdf.columns
|
||||
assert sorted(gdf["등고수치"].tolist()) == [100.0, 105.0]
|
||||
assert set(gdf["구분"]) == {"주곡선"}
|
||||
|
||||
|
||||
def test_with_cpg_unchanged(tmp_path):
|
||||
gdf = _read(_sheet_zip(tmp_path, with_cpg=True))
|
||||
assert sorted(gdf["등고수치"].tolist()) == [100.0, 105.0]
|
||||
|
||||
|
||||
def test_merge_keys_carry_parser_version():
|
||||
assert parser.PARSER_VERSION >= 2
|
||||
for rel in (
|
||||
"B04_PreProcess/B04_PreProcess_Engine.py",
|
||||
"D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py",
|
||||
):
|
||||
text = (ROOT / rel).read_text(encoding="utf-8")
|
||||
assert '"parser": PARSER_VERSION' in text, rel
|
||||
@@ -0,0 +1,203 @@
|
||||
"""D03 수동 그리기 셈 거울(PLAN 562-1) — 파이썬 `D03_PlanAnalysis_Draw` ↔
|
||||
TS `logic/D03_PlanAnalysis_Draw.ts`.
|
||||
|
||||
같은 높이 함수(`hill` · x > 800 = 지형 밖)로 두 쪽 값이 같은지 + 손셈 값.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_562_1_plan_draw.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Draw as draw # noqa: E402
|
||||
|
||||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
|
||||
def hill(x: float, y: float) -> float:
|
||||
if x > 800:
|
||||
return math.nan
|
||||
return 0.0005 * x * x + 3 * math.sin(y / 40)
|
||||
|
||||
|
||||
def plane(x: float, y: float) -> float:
|
||||
return 0.1 * x
|
||||
|
||||
|
||||
LINE = [[0.0, 0.0], [30.0, 40.0], [30.0, 100.0], [90.0, 180.0]] # 50 + 60 + 100 = 210 m
|
||||
|
||||
# (함수 이름, 인자) — 높이 자리는 "hill" · "plane" 글자로 넘김
|
||||
CASES = [
|
||||
("segmentLength", [[1, 2], [4, 6]]),
|
||||
("groundSlopePct", [[0, 0], [100, 30], "hill"]),
|
||||
("groundSlopePct", [[100, 30], [0, 0], "hill"]),
|
||||
("groundSlopePct", [[700, 0], [900, 0], "hill"]),
|
||||
("groundSlopePct", [[5, 5], [5, 5], "hill"]),
|
||||
("fixedLengthPoint", [[10, 10], [13, 14], 20]),
|
||||
("fixedLengthPoint", [[10, 10], [10, 10], 20]),
|
||||
("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill"]),
|
||||
("fixedSlopePoint", [[0, 0], [50, 0], -9, "hill"]),
|
||||
("fixedSlopePoint", [[100, 30], [130, 70], 12, "hill"]),
|
||||
("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill", 100]),
|
||||
("fixedSlopePoint", [[600, 0], [700, 0], 1, "hill"]),
|
||||
("compassCandidates", [[0, 0], 50, 5, "plane"]),
|
||||
("compassCandidates", [[200, 10], 60, 15, "hill"]),
|
||||
("compassCandidates", [[0, 0], 50, 50, "plane"]),
|
||||
("compassPoint", [[0, 0], [10, 40], 50, 5, "plane"]),
|
||||
("compassPoint", [[0, 0], [10, 40], 50, 50, "plane"]),
|
||||
("lineLength", [LINE]),
|
||||
("pointAtDistance", [LINE, -5]),
|
||||
("pointAtDistance", [LINE, 75]),
|
||||
("pointAtDistance", [LINE, 500]),
|
||||
("distanceAlong", [LINE, [40, 70]]),
|
||||
("distanceAlong", [LINE, [-10, -10]]),
|
||||
("sliceLine", [LINE, 25, 160]),
|
||||
("sliceLine", [LINE, 160, 25]),
|
||||
("stationChainages", [210]),
|
||||
("stationChainages", [40, 20]),
|
||||
("stationChainages", [0]),
|
||||
]
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import * as draw from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js";
|
||||
const heights = {
|
||||
hill: (x, y) => (x > 800 ? NaN : 0.0005 * x * x + 3 * Math.sin(y / 40)),
|
||||
plane: (x, y) => 0.1 * x,
|
||||
};
|
||||
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const out = cases.map(([name, args]) =>
|
||||
draw[name](...args.map((a) => (typeof a === "string" ? heights[a] : a))),
|
||||
);
|
||||
writeFileSync(process.argv[3], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
_HEIGHTS = {"hill": hill, "plane": plane}
|
||||
_PY = {
|
||||
"segmentLength": draw.segment_length,
|
||||
"groundSlopePct": draw.ground_slope_pct,
|
||||
"fixedLengthPoint": draw.fixed_length_point,
|
||||
"fixedSlopePoint": draw.fixed_slope_point,
|
||||
"compassCandidates": draw.compass_candidates,
|
||||
"compassPoint": draw.compass_point,
|
||||
"lineLength": draw.line_length,
|
||||
"pointAtDistance": draw.point_at_distance,
|
||||
"distanceAlong": draw.distance_along,
|
||||
"sliceLine": draw.slice_line,
|
||||
"stationChainages": draw.station_chainages,
|
||||
}
|
||||
|
||||
|
||||
def _py(name: str, args: list) -> object:
|
||||
return _PY[name](*(_HEIGHTS[a] if isinstance(a, str) else a for a in args))
|
||||
|
||||
|
||||
def _close(a: object, b: object) -> bool:
|
||||
if a is None or b is None:
|
||||
return a is b
|
||||
if isinstance(a, (int, float)):
|
||||
return isinstance(b, (int, float)) and abs(a - b) < 1e-6
|
||||
return len(a) == len(b) and all(_close(x, y) for x, y in zip(a, b)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts_out(tmp_path_factory: pytest.TempPathFactory) -> list:
|
||||
tmp = tmp_path_factory.mktemp("draw")
|
||||
out = tmp / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(TS_FILE),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--rootDir",
|
||||
str(ROOT),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.rglob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
job = tmp / "job.json"
|
||||
result = tmp / "out.json"
|
||||
job.write_text(json.dumps(CASES), encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(job), str(result)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_ts_matches_python(ts_out: list) -> None:
|
||||
for (name, args), ts in zip(CASES, ts_out):
|
||||
assert _close(ts, _py(name, args)), (name, args, ts, _py(name, args))
|
||||
|
||||
|
||||
def test_hand_values() -> None:
|
||||
assert draw.segment_length((1, 2), (4, 6)) == 5
|
||||
# 오르막 + · 내리막 − · 지형 밖 · 길이 0
|
||||
up = draw.ground_slope_pct((0, 0), (100, 0), hill)
|
||||
assert up == pytest.approx(5.0)
|
||||
assert draw.ground_slope_pct((100, 0), (0, 0), hill) == pytest.approx(-5.0)
|
||||
assert draw.ground_slope_pct((700, 0), (900, 0), hill) is None
|
||||
assert draw.ground_slope_pct((5, 5), (5, 5), hill) is None
|
||||
assert draw.fixed_length_point((10, 10), (13, 14), 20) == pytest.approx((22, 26))
|
||||
# z = 0.0005 x² · x0 = 0 → 평균 기울기 0.0005 t = 9 % 첫 자리 t = 180
|
||||
# 부호 무관 · 한도 100 m = 없음
|
||||
assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill) == pytest.approx((180, 0), abs=1e-6)
|
||||
assert draw.fixed_slope_point((0, 0), (50, 0), -9, hill) == pytest.approx((180, 0), abs=1e-6)
|
||||
assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill, 100) is None
|
||||
# 지형 밖에 닿음
|
||||
assert draw.fixed_slope_point((600, 0), (700, 0), 1, hill) is None
|
||||
# 평면 10 % · 반지름 50 · 5 % → cos θ = ±0.5 · 60 · 120 · 240 · 300°
|
||||
got = draw.compass_candidates((0, 0), 50, 5, plane)
|
||||
angles = sorted(round(math.degrees(math.atan2(y, x)) % 360, 6) for x, y in got)
|
||||
assert angles == [60, 120, 240, 300]
|
||||
assert draw.compass_candidates((0, 0), 50, 50, plane) == []
|
||||
near = draw.compass_point((0, 0), (10, 40), 50, 5, plane)
|
||||
assert near == pytest.approx((25, 25 * math.sqrt(3)), abs=1e-6)
|
||||
assert draw.compass_point((0, 0), (10, 40), 50, 50, plane) is None
|
||||
|
||||
|
||||
def test_line_values() -> None:
|
||||
assert draw.line_length(LINE) == 210
|
||||
assert draw.point_at_distance(LINE, -5) == (0, 0)
|
||||
assert draw.point_at_distance(LINE, 75) == (30, 65)
|
||||
assert draw.point_at_distance(LINE, 500) == (90, 180)
|
||||
assert draw.distance_along(LINE, (40, 70)) == 80
|
||||
assert draw.distance_along(LINE, (-10, -10)) == 0
|
||||
assert draw.slice_line(LINE, 25, 160) == pytest.approx(
|
||||
[(15, 20), (30, 40), (30, 100), (60, 140)]
|
||||
)
|
||||
assert draw.slice_line(LINE, 160, 25) == draw.slice_line(LINE, 25, 160)
|
||||
assert draw.station_chainages(210) == [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 210]
|
||||
assert draw.station_chainages(40, 20) == [0, 20, 40]
|
||||
assert draw.station_chainages(0) == [0]
|
||||
@@ -25,9 +25,6 @@ export const ui_locales_e03 = {
|
||||
E03_Fresh: ["입력이 같아 다시 셀 것이 없습니다.", "Inputs unchanged — nothing to recompute."],
|
||||
E03_View3d: ["3D 보기", "3D View"],
|
||||
E03_View3d_Legend: ["최소 안전율 ÷ 기준안전율", "Min FS ÷ required FS"],
|
||||
E03_RepSave: ["대표 단면 저장", "Save Representative"],
|
||||
E03_RepSaved: ["대표 단면을 저장했습니다.", "Representative sections saved."],
|
||||
E03_RepUnsaved: ["대표 단면 바뀜 — 저장 안 됨", "Representative changes not saved"],
|
||||
E03_Loading: ["불러오는 중…", "Loading…"],
|
||||
E03_NotYet: [
|
||||
"아직 해석하지 않았습니다 — [해석]을 누르세요.",
|
||||
|
||||
Reference in New Issue
Block a user