fix(git): 병합이 떨군 파일 22개와 되돌아간 파일 35개를 되살림

무슨 일이 있었나
랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into
sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운
커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`·
`CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴.

잃었던 것
- 공용 — `common_util_provenance.py` · `ui_template_provenance.ts`
- B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸
- B09 — 근거 사전 셋
- B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈
- 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등)

어떻게 되살렸나
`611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림.
이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난
옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛
상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 —
`9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑).
위키 여덟은 코덱스 몫이라 손대지 않음.

자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 ·
`B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의
`attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped**
(되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
This commit is contained in:
2026-09-12 18:18:57 +09:00
co-authored by Claude Opus 5
parent 216d027e9c
commit f952ac7ffd
55 changed files with 6030 additions and 764 deletions
+178 -217
View File
@@ -42,14 +42,14 @@ export const ROUTE_LINE_WIDTH = 2.4;
* 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다.
* line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다.
*/
type PreparedPart = {
export type PreparedPart = {
coords: Float64Array;
closed: boolean;
weights: Float64Array | null;
};
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
type PreparedFeature = {
export type PreparedFeature = {
kind: "line" | "point";
parts: PreparedPart[];
minX: number;
@@ -60,6 +60,8 @@ type PreparedFeature = {
labelAnchorX: number;
labelAnchorY: number;
labelText: string | null;
/** 그 라벨의 표고(m). 어느 줄을 실제로 낼지는 `drawPreparedLabels` 가 줌을 보고 고른다. */
labelValue: number | null;
};
export type PreparedLayer = {
@@ -214,226 +216,81 @@ export function computeRouteView(
};
}
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
const push = (ring: unknown, closed: boolean): void => {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed, weights: null });
};
switch (geometry.type) {
case "Point":
push([coordinates], false);
return "point";
case "MultiPoint":
push(coordinates, false);
return "point";
case "LineString":
push(coordinates, false);
return "line";
case "MultiLineString":
for (const line of coordinates) push(line, false);
return "line";
case "Polygon":
for (const ring of coordinates) push(ring, true);
return "line";
case "MultiPolygon":
for (const polygon of coordinates) {
if (!Array.isArray(polygon)) continue;
for (const ring of polygon) push(ring, true);
}
return "line";
default:
return "line";
}
}
/**
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
*/
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
const n = coords.length / 2;
const weights = new Float64Array(n);
weights[0] = Infinity;
weights[n - 1] = Infinity;
if (n <= 2) return weights;
const stack: number[] = [0, n - 1];
const caps: number[] = [Infinity];
while (stack.length) {
const last = stack.pop()!;
const first = stack.pop()!;
const cap = caps.pop()!;
if (last - first < 2) continue;
const ax = coords[first * 2];
const ay = coords[first * 2 + 1] / aspect;
const bx = coords[last * 2];
const by = coords[last * 2 + 1] / aspect;
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
let maxDist = -1;
let maxIndex = -1;
for (let i = first + 1; i < last; i += 1) {
const px = coords[i * 2] - ax;
const py = coords[i * 2 + 1] / aspect - ay;
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
const weight = Math.min(maxDist, cap);
weights[maxIndex] = weight;
stack.push(first, maxIndex, maxIndex, last);
caps.push(weight, weight);
}
return weights;
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 컬렉션 1개를 사전 투영한다.
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
if (kind === "line") {
for (const part of parts) {
if (part.coords.length < 6) continue;
part.weights = computeDpWeights(part.coords, normalizer.aspect);
}
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
/** 화면 px 에 가장 가까운 선 피처의 자리. 그만큼 안에 없으면 -1(계획서 0-9 ⑦). */
export function hitPreparedLayer(
layer: PreparedLayer,
view: ViewState,
px: number,
py: number,
tolerancePx: number,
everyM?: number,
): number {
const affine = affineOf(view);
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
let best = -1;
let bestDistance = tolerancePx;
layer.features.forEach((feature, index) => {
if (feature.kind !== "line") return;
// **그리지 않은 줄은 집히지도 않는다** — 안 보이는 등고선이 골라지면 없던 선이 튀어나온다.
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) return;
// 화면 밖·멀리 있는 피처는 바운딩박스에서 먼저 떨군다 — 도엽 등고선은 수천 가닥이다.
const x0 = feature.minX * affine.ax + affine.bx - tolerancePx;
const x1 = feature.maxX * affine.ax + affine.bx + tolerancePx;
const y0 = feature.minY * affine.ay + affine.by - tolerancePx;
const y1 = feature.maxY * affine.ay + affine.by + tolerancePx;
if (px < x0 || px > x1 || py < y0 || py > y1) return;
for (const part of feature.parts) {
const coords = part.coords;
let lastX = NaN;
let lastY = NaN;
// 그릴 때와 **같은 LOD** 로 훑는다 — 화면에 없는 정점에 걸리면 눈과 손이 어긋난다.
const tolerance = LOD_PX / affine.ax;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (Number.isFinite(elevation) && elevation % 25 === 0) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
if (part.weights && part.weights[i / 2] < tolerance) continue;
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (Number.isFinite(lastX)) {
const distance = pointSegmentDistance(px, py, lastX, lastY, x, y);
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
}
lastX = x;
lastY = y;
}
}
features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText });
}
return { features };
});
return best;
}
/**
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
*/
export function prepareMetricPolyline(
points: ReadonlyArray<{ x: number; y: number }>,
meta: VWorldMeta,
): PreparedLayer {
if (points.length < 2) return { features: [] };
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const coords = new Float64Array(points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
points.forEach((point, index) => {
const nx = (point.x - meta.x_min) / widthMeters;
const ny = 1 - (point.y - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
return {
features: [
{
kind: "line",
parts: [{ coords, closed: false, weights: null }],
minX,
minY,
maxX,
maxY,
labelAnchorX: 0,
labelAnchorY: 0,
labelText: null,
},
],
};
/** 레이어 안의 피처 하나만 다시 그린다 — 고른 등고선을 도드라지게 할 때 쓴다. */
export function drawPreparedFeature(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
index: number,
view: ViewState,
): void {
const feature = layer.features[index];
if (!feature || feature.kind !== "line") return;
drawLineParts(context, feature, affineOf(view));
}
/** 점과 선분 사이 거리(px). */
function pointSegmentDistance(
px: number,
py: number,
ax: number,
ay: number,
bx: number,
by: number,
): number {
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared <= 1e-9) return Math.hypot(px - ax, py - ay);
const ratio = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
return Math.hypot(px - (ax + dx * ratio), py - (ay + dy * ratio));
}
/**
@@ -563,6 +420,9 @@ function drawPointParts(
/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */
const CULL_MARGIN = 32;
/** 등고 라벨끼리 이만큼(px)은 떨어져야 둘 다 낸다 — 가로 여백과 줄 높이. */
const LABEL_GAP_PX = 10;
const LABEL_ROW_PX = 14;
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
const margin = CULL_MARGIN;
@@ -578,41 +438,142 @@ function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): b
);
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */
/** 레이어가 차지하는 **화면 사각형**(px). 피처가 없으면 null.
*
* LAS 등고선처럼 도엽보다 좁은 자료 위에 다른 레이어를 겹칠 때, 그 자료가 있는 데까지만
* 그리려고 쓴다(계획서 0-9 ⑮ — 세류선이 등고선 밖까지 뻗던 자리). */
export function layerScreenBounds(
layer: PreparedLayer,
view: ViewState,
): { x: number; y: number; width: number; height: number } | null {
if (layer.features.length === 0) return null;
const affine = affineOf(view);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const feature of layer.features) {
minX = Math.min(minX, feature.minX);
minY = Math.min(minY, feature.minY);
maxX = Math.max(maxX, feature.maxX);
maxY = Math.max(maxY, feature.maxY);
}
const x0 = minX * affine.ax + affine.bx;
const x1 = maxX * affine.ax + affine.bx;
const y0 = minY * affine.ay + affine.by;
const y1 = maxY * affine.ay + affine.by;
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
}
/** 등고선을 몇 m 마다 낼지 고를 때 훑는 배수. 성긴 쪽으로 한 칸씩 물러난다. */
const LEVEL_STEP_MULTIPLES = [1, 2, 5, 10, 20, 50, 100];
/** 한 화면에 둘 등고선 가닥 수의 어림 상한 — 이보다 많으면 한 칸 성글게 간다. */
const LEVEL_BUDGET = 350;
/**
* 지금 화면에 **몇 m 간격**으로 등고선을 낼지 고른다.
*
* 간격을 줌으로만 정하면 가파른 데서는 여전히 선이 뭉개지고 완만한 데서는 너무 성기다.
* 그래서 **지금 화면에 실제로 들어오는 가닥 수**를 세어 상한을 넘지 않는 가장 촘촘한 간격을
* 고른다 — 확대하면 저절로 촘촘해지고 물러나면 성겨진다(2026-09-12 실화면: 1m LAS 등고선을
* 다 그리면 지형이 선으로 덮였다).
*/
export function pickLevelStep(
layer: PreparedLayer,
view: ViewState,
intervalM: number,
budget = LEVEL_BUDGET,
): number {
const interval = intervalM > 0 ? intervalM : 1;
const affine = affineOf(view);
let step = interval * LEVEL_STEP_MULTIPLES[LEVEL_STEP_MULTIPLES.length - 1];
for (const multiple of LEVEL_STEP_MULTIPLES) {
const candidate = interval * multiple;
let count = 0;
for (const feature of layer.features) {
if (feature.labelValue !== null && feature.labelValue % candidate !== 0) continue;
if (!isVisible(feature, affine, view)) continue;
count += 1;
if (count > budget) break;
}
if (count <= budget) return candidate;
step = candidate;
}
return step;
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다.
*
* `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이
* 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. */
export function drawPreparedLayer(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
marker: MarkerKind,
everyM?: number,
): void {
const affine = affineOf(view);
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
for (const feature of layer.features) {
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) continue;
if (!isVisible(feature, affine, view)) continue;
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
else drawLineParts(context, feature, affine);
}
}
/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */
/** 사전 계산된 등고 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다.
*
* `everyM` 은 **몇 m 마다 한 줄을 라벨할지**다. 기본 25m(계곡선)는 B04 지도가 쓰던 값 그대로다
* — 전부 내면 화면이 숫자로 뒤덮인다. 확대가 큰 화면은 더 작은 값을 넘겨 촘촘히 낸다. */
export function drawPreparedLabels(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
color: string,
everyM = 25,
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. */
uprightRad = 0,
): void {
const affine = affineOf(view);
const margin = CULL_MARGIN;
const step = everyM > 0 ? everyM : 25;
// 이미 찍은 라벨과 겹치면 건너뛴다 — LAS 등고선은 **한 표고가 여러 가닥**으로 끊겨 있어
// 가닥마다 숫자를 내면 화면이 숫자로 덮인다(2026-09-12 실화면). 도엽 계곡선은 원래
// 드물어 이 규칙에 걸리지 않으므로 B04 지도의 표기는 그대로다.
const drawn: Array<{ x: number; y: number; half: number }> = [];
for (const feature of layer.features) {
if (feature.labelText === null) continue;
// 표고를 못 읽은 라벨(값 없음)은 솎지 않고 그대로 낸다.
if (feature.labelValue !== null && feature.labelValue % step !== 0) continue;
const x = feature.labelAnchorX * affine.ax + affine.bx;
const y = feature.labelAnchorY * affine.ay + affine.by;
if (x < -margin || x > view.width + margin) continue;
if (y < -margin || y > view.height + margin) continue;
const half = context.measureText(feature.labelText).width / 2 + LABEL_GAP_PX;
if (
drawn.some(
(item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < LABEL_ROW_PX,
)
) {
continue;
}
drawn.push({ x, y, half });
context.save();
if (uprightRad) {
// 글자 **자리는 그대로** 두고 글자만 되돌린다 — 180°에서 숫자가 뒤집혀 안 읽힌다.
context.translate(x, y);
context.rotate(uprightRad);
context.translate(-x, -y);
}
context.lineWidth = 3;
context.strokeStyle = haloColor();
context.strokeText(feature.labelText, x, y);
context.fillStyle = color;
context.fillText(feature.labelText, x, y);
context.restore();
}
}