Merge remote-tracking branch 'origin/dev' into sub_desktop_1

This commit is contained in:
2026-09-12 15:00:05 +09:00
44 changed files with 30942 additions and 778 deletions
+141 -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,36 +438,100 @@ function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): b
);
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */
/** 등고선을 몇 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,
): 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.lineWidth = 3;
context.strokeStyle = haloColor();
context.strokeText(feature.labelText, x, y);
@@ -0,0 +1,306 @@
/* =============================================================================
* B04_PreProcess_UI_MapRender_Prepare.ts
* 지도 레이어 **사전 투영** — GeoJSON·사업지 좌표 폴리라인을 정규화 좌표로 펴고,
* 줌 무손실 LOD 가중치(Douglas-Peucker)와 등고 라벨 앵커를 미리 잡아 둔다.
*
* `B04_PreProcess_UI_MapRender.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12).
* 본문 로직과 수치는 그대로다. 그리기는 그쪽, 준비는 이쪽 — 한 방향으로만 기대어
* 순환 참조가 생기지 않는다.
* ========================================================================== */
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
import type {
GeoJsonCollection,
GeoJsonGeometry,
Normalizer,
PreparedFeature,
PreparedLayer,
PreparedPart,
} from "./B04_PreProcess_UI_MapRender";
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) {
const coords = part.coords;
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 labelValue: number | 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);
// **모든 등고선**에 앵커를 잡아 둔다. 어느 줄을 실제로 낼지는 그릴 때 고른다 —
// 화면마다 솎는 눈금이 다르기 때문이다(B04 지도는 계곡선만, 계획노선 편집 모달은
// 확대에 따라 더 촘촘히). 준비 단계에서 걸러 버리면 확대해도 되살릴 수가 없다.
if (Number.isFinite(elevation)) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelValue = elevation;
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
}
}
}
features.push({
kind,
parts,
minX,
minY,
maxX,
maxY,
labelAnchorX,
labelAnchorY,
labelText,
labelValue,
});
}
return { features };
}
/**
* 사업지 좌표계(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,
labelValue: null,
},
],
};
}
/**
* 사업지 좌표계(m) 폴리라인 **여러 개**를 한 레이어로 사전 투영한다(LAS 등고선 등).
*
* `prepareMetricPolyline` 의 여러 줄 판이다. 줄마다 `label`(표고 m)을 주면 가운데 정점을
* 앵커로 잡아 `drawPreparedLabels` 가 그대로 쓸 수 있다.
*/
export function prepareMetricPolylines(
lines: ReadonlyArray<{ points: ReadonlyArray<readonly [number, number]>; label?: number }>,
meta: VWorldMeta,
): PreparedLayer {
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const features: PreparedFeature[] = [];
for (const line of lines) {
if (line.points.length < 2) continue;
const coords = new Float64Array(line.points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
line.points.forEach((point, index) => {
const nx = (point[0] - meta.x_min) / widthMeters;
const ny = 1 - (point[1] - 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;
});
const middle = Math.floor(line.points.length / 2) * 2;
features.push({
kind: "line",
parts: [
{ coords, closed: false, weights: computeDpWeights(coords, widthMeters / heightMeters) },
],
minX,
minY,
maxX,
maxY,
labelAnchorX: coords[middle],
labelAnchorY: coords[middle + 1],
labelText: line.label === undefined ? null : String(line.label),
labelValue: line.label ?? null,
});
}
return { features };
}
@@ -33,8 +33,6 @@ import {
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
prepareLayer,
prepareMetricPolyline,
routeLineColor,
ROUTE_LINE_WIDTH,
type GeoJsonCollection,
@@ -44,6 +42,7 @@ import {
type PreparedLayer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import { prepareLayer, prepareMetricPolyline } from "./B04_PreProcess_UI_MapRender_Prepare";
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
+59 -1
View File
@@ -56,8 +56,12 @@ export interface RoutePlanResponse {
nodes: RoutePlanNode[];
/** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */
curves: RoutePlanCurve[];
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m) — **기본값·위반 표시 기준**. */
min_radius_m: number;
/** **못 넘는** 곡선반지름 하한(m). 0이면 제한 없음(작업임도). 기본값과 다른 값이다. */
limit_radius_m?: number;
/** **못 넘는** 곡선 길이 하한(m). 0이면 제한 없음 — 지금은 전부 0(법에 값이 없음). */
limit_curve_length_m?: number;
curve_count: number;
violation_count: number;
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
@@ -128,6 +132,60 @@ export async function replanRoute(
);
}
/** 점 묶음의 **지반고**를 묻는다(계획서 0-9 ⑤·⑧).
*
* 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안
* 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다. */
export async function fetchRouteElevations(
projectId: string,
points: Array<[number, number]>,
): Promise<Array<number | null>> {
const payload = await requestJson<{ z: Array<number | null> }>(
`/projects/${projectId}/route/elevations`,
{ method: "POST", body: JSON.stringify({ points }) },
60000,
);
return payload.z;
}
/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */
export interface CrossPreviewResponse {
status: string;
chainage_m: number;
label: string | null;
uphill_side: string | null;
plan_radius_m: number | null;
curve_widening_m: number | null;
/** 원지반 횡단 샘플. */
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
design: {
design_line: Array<{ offset_m: number; elevation_m: number }>;
cut_area_m2: number;
fill_area_m2: number;
[key: string]: unknown;
} | null;
}
export interface CrossPreviewRequest {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
chainage_m: number;
min_radius_m: number;
station_interval_m: number;
}
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
export async function fetchCrossPreview(
projectId: string,
request: CrossPreviewRequest,
): Promise<CrossPreviewResponse> {
return requestJson<CrossPreviewResponse>(
`/projects/${projectId}/route/cross-preview`,
{ method: "POST", body: JSON.stringify(request) },
120000,
);
}
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
return requestJson<RouteReplanResponse>(
+31
View File
@@ -151,6 +151,37 @@ def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal")
return float(speeds[terrain])
def plan_radius_limit_m(
grade_class: str,
design_speed_kph: int | None = None,
terrain_type: str = "normal",
) -> float:
"""계획노선 편집 화면이 **못 넘게 막을** 평면 곡선반지름 하한(m). 0이면 제한 없음.
위 `legal_plan_radius_min_m` 은 **기본값·위반 표시 기준**이고 이것은 **제한**이다
(2026-09-12 사용자 확정: 「아예 못 넘게 막음」). 임도 종류별 칸이 비어 있으면(None)
법정 표를 그대로 하한으로 쓰고, 값이 적혀 있으면 그 값을 쓴다 — 작업임도는 별표2에
곡선반지름 규정이 없어 0(제한 없음)으로 열려 있다.
"""
table = FOREST_ROAD_PROFILE_CRITERIA["plan_radius_limit_by_grade_m"]
override = table.get(grade_class)
if override is not None:
return float(override)
return legal_plan_radius_min_m(
resolve_design_speed(grade_class, design_speed_kph), terrain_type
)
def plan_curve_length_limit_m(grade_class: str) -> float:
"""평면 **곡선 길이(L)** 하한(m). 0이면 제한 없음.
법령·교본에 값이 없어 지금은 임도 종류 전부 0이다 — 자리만 열어 둔 칸이라
실무값이 정해지면 `config_system_design` 의 표만 고치면 된다(2026-09-12 사용자 확정).
"""
table = FOREST_ROAD_PROFILE_CRITERIA["plan_curve_length_limit_by_grade_m"]
return float(table.get(grade_class) or 0.0)
def _pick(*candidates: Any) -> Any:
"""요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다."""
for value in candidates:
+31 -6
View File
@@ -133,7 +133,19 @@ def _ensure_expected_route(project_root: Path) -> str:
async def _min_plan_radius_m(project_id: UUID) -> float:
"""이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다.
"""기본 반지름만 필요한 자리 — 하한까지 필요하면 `_plan_criteria` 를 쓸 것."""
criteria = await _plan_criteria(project_id)
return criteria[0]
async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]:
"""이 프로젝트의 **기본 반지름 · 반지름 하한 · 곡선 길이 하한**(m) 세 값.
기본 반지름은 곡선을 만들 때 쓰는 값이고, 하한 둘은 **화면이 못 넘게 막는** 값이다
(2026-09-12 사용자 확정). 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어 곡선이
아예 안 그려지므로 반드시 갈라 둔다.
기본 반지름은 임도 종류·설계속도·지형으로 고른다.
값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 Ⅰ.2.다)이고 산식은 이미
`B05_Profile_Engine_Grade.legal_plan_radius_min_m` 에 있다 — 여기서 다시 짜지 않는다.
@@ -145,7 +157,12 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
"""
import aiomysql
from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed
from B05_Profile.B05_Profile_Engine_Grade import (
legal_plan_radius_min_m,
plan_curve_length_limit_m,
plan_radius_limit_m,
resolve_design_speed,
)
from common_util.common_util_workflow_state import get_workflow_state
grade_class, design_speed, terrain = "work", None, "special"
@@ -172,7 +189,11 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
terrain = str(params["terrain_type"])
except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다
logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id)
return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain)
return (
legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain),
plan_radius_limit_m(grade_class, design_speed, terrain),
plan_curve_length_limit_m(grade_class),
)
def _nodes_path(path: Path) -> Path:
@@ -404,7 +425,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
if not expected:
expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root))
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root))
initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root))
@@ -445,6 +466,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
# (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다.
"curves": saved_curves or [curve.as_dict() for curve in outline.curves],
"min_radius_m": round(radius_m, 2),
# **못 넘는 하한** — 기본값(`min_radius_m`)과 다른 값이다. 0이면 제한 없음
# (작업임도는 별표2에 곡선반지름 규정이 없어 0으로 열려 있다, 2026-09-12 확정).
"limit_radius_m": round(radius_limit_m, 2),
"limit_curve_length_m": round(arc_limit_m, 2),
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
"violation_count": outline.violation_count,
"edited": bool(working),
@@ -468,7 +493,7 @@ async def replan_route(
# 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다.
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
@@ -527,7 +552,7 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, stored_path = paths
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working_path = planned_route_working_path(project_root)
if working_path.is_file():
+221
View File
@@ -0,0 +1,221 @@
"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로.
편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 점을 찍어
**구간 길이와 종단기울기** (0-9 ) 측점의 **횡단도 미리보기**() 지반고가
있어야 한다. 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 규칙과 부딪히지
않는다 노선을 갈아 끼우지도, 정본을 건드리지도 않는다.
표고 조회는 ·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`) 연다.
화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
POST /api/projects/{id}/route/elevations 묶음의 지반고
POST /api/projects/{id}/route/cross-preview 고치던 노선의 측점 횡단 미리보기
"""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
import numpy as np
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from common_util.common_util_route_polyline import build_planned_polyline
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Route Terrain"])
_MODELS_SUBDIR = Path("B04_PreProcess") / "models"
#: 한 번에 물을 수 있는 점 수. 구간 재기는 수십 점, 횡단 한 장은 수백 점이면 넉넉하다 —
#: 상한을 두어 실수로 노선 전체를 밀어 넣는 일을 막는다.
MAX_POINTS = 4000
class ElevationRequest(BaseModel):
"""사업지 좌표계(m) 점 묶음 [[x, y], …]."""
points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS)
def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]):
"""확정 지표면에서 표고를 읽는다. 모델을 못 열면 None."""
try:
sampler = build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("계획노선 편집: 지표면을 열지 못했습니다 — %s", exc)
return None
z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64))
return z, valid
@router.post("/{project_id}/route/elevations", response_model=None)
async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> dict | JSONResponse:
"""점 묶음의 지반고(m)와 유효 여부를 돌려준다.
지표면 밖이거나 자료가 없는 자리는 `valid=false` 나가고 표고는 `null` 이다
**임의 표고로 메우지 않는다**(sampler 규칙 그대로). 화면은 자리를 모름으로 낸다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
)
params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored))
sampled = await asyncio.to_thread(_sample, project_root, params, request.points)
if sampled is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "확정된 지표면이 없어 지반고를 읽을 수 없습니다.",
},
)
z, valid = sampled
return {
"status": "success",
"project_id": str(project_id),
"z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)],
"valid": [bool(ok) for ok in valid],
}
class PreviewVertex(BaseModel):
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
x: float
y: float
curve: bool = True
radius_m: float | None = None
class CrossPreviewRequest(BaseModel):
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
vertices: list[PreviewVertex] = Field(..., min_length=2)
chainage_m: float = Field(..., ge=0)
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
min_radius_m: float = Field(12.0, gt=0)
station_interval_m: float | None = None
def _cross_preview(
project_root: Path,
params: dict,
request: CrossPreviewRequest,
) -> dict | None:
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
**B05·B06 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 기본 로직은 B06에
존재함. 재사용) `generate_sections` 측점·접선·지반 샘플을, `compute_cross_design`
설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
**계획고는 아직 없다.** 계획고는 [확인] 체인이 낳는 값이라 편집 중에는 존재하지
않는다. 그래서 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) ·성토가 사면
기울기만으로 서는 기본 계획 횡단이며, 사용자가 보기로 것도 그것이다.
"""
try:
sampler = build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
return None
built = build_planned_polyline(
[(vertex.x, vertex.y) for vertex in request.vertices],
min_radius_m=request.min_radius_m,
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
simplify=False,
curve_flags=[vertex.curve for vertex in request.vertices],
radii=[vertex.radius_m for vertex in request.vertices],
)
interval = request.station_interval_m
options = (
SectionGenerationOptions(station_interval_m=float(interval))
if interval and interval > 0
else SectionGenerationOptions()
)
result = generate_sections(built.vertices, sampler, options)
sections = result["cross_sections"]
if not sections:
return None
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
design = None
center_z = section.get("center_z")
if center_z is not None:
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
design = compute_cross_design(
section["samples"],
float(center_z),
ground_type="soil",
section_mode=section_mode,
**curve_widening_args(section),
)
return {
"chainage_m": round(float(section["chainage_m"]), 3),
"label": section.get("label"),
"uphill_side": section.get("uphill_side"),
"plan_radius_m": section.get("plan_radius_m"),
"curve_widening_m": section.get("curve_widening_m"),
"samples": section["samples"],
"design": design,
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
if result.get("longitudinal", {}).get("total_length_m") is not None
else None,
}
@router.post("/{project_id}/route/cross-preview", response_model=None)
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
정본을 건드리지 않는다 파일도 DB 쓰지 않고 자리에서 셈해 돌려주기만 한다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
)
params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored))
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
if preview is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
},
)
return {"status": "success", "project_id": str(project_id), **preview}
+4 -2
View File
@@ -15,13 +15,15 @@ import {
computeMapRect,
MAP_STATION_INTERVAL_M,
createNormalizer,
prepareLayer,
prepareMetricPolyline,
type MapRect,
type Normalizer,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
prepareLayer,
prepareMetricPolyline,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import { type RoutePoint } from "./B05_Profile_Api_Fetch";
import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
+14 -3
View File
@@ -281,9 +281,20 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
onEditPlannedRoute: () =>
void openRouteEditModal(activeProjectId, () => {
navigateTo(ROUTES.B05_PROFILE);
}),
void openRouteEditModal(
activeProjectId,
() => {
navigateTo(ROUTES.B05_PROFILE);
},
{
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
stationIntervalM: panel.values().stationInterval ?? undefined,
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
surfaceModelId: confirmedSurface?.model_id ?? null,
contourIntervalM: latest?.surface_params.contour_interval_m,
smooth: latest?.surface_params.smooth,
},
),
onTempSave: () => void tempSaveAction(actionContext),
onGoCross: () => {
// 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
+217 -255
View File
@@ -16,36 +16,44 @@
import {
computeMapRect,
computeRouteView,
drawPreparedLayer,
createNormalizer,
hitPreparedLayer,
metricToScreen,
prepareLayer,
pickLevelStep,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
import type { RoutePlanCurve } from "./B05_Profile_Api_Replan";
import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve";
import { fetchRoutePlan } from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
import {
buildEditedPolyline,
dragHandleTo as curveDragTo,
type EditedCurve,
type EditedNode,
} from "./B05_Profile_UI_RouteEdit_Curve";
import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render";
import {
bindRouteEditNavigation,
contourBandRect,
handleAtScreen,
nodeAtScreen,
segmentAtScreen,
stationAtScreen,
} from "./B05_Profile_UI_RouteEdit_Input";
import {
centerDirectionOf,
createCurveLabel,
deflectionRad,
} from "./B05_Profile_UI_RouteEdit_Label";
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import {
applyArcLocks,
applyCurveLimits,
curveShortfalls,
curveSummary,
flattenServerPlan,
shortfallCrossed,
type CurveLock,
} from "./B05_Profile_UI_RouteEdit_Edits";
import {
@@ -58,27 +66,32 @@ import "./B05_Profile_UI_Style_RouteEdit.css";
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
const NODE_HIT_PX = 9;
/** 노드 반지름(px). */
const NODE_R = 4;
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
const SEGMENT_HIT_PX = 12;
/** ** **(m) (2026-09-07).
*
* . ** **
* ( ).
* `drawPreparedLayer` . */
const CONTOUR_BAND_M = 300;
/** · (px) ** ** .
* 3.5px (2026-09-07 ). */
const CURVE_HANDLE_PX = 5;
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
const CONTOUR_HIT_PX = 6;
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
const STATION_HIT_PX = 11;
type Vertex = [number, number];
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
export interface RouteEditOptions {
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
stationIntervalM?: number;
/** 확정 지표면 모델 id — 있으면 바탕 등고선을 **LAS 것**으로 쓴다(계획서 0-9 ⑥). */
surfaceModelId?: number | null;
/** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */
contourIntervalM?: number;
smooth?: boolean;
}
export async function openRouteEditModal(
projectId: string,
onApplied: () => void | Promise<void>,
options: RouteEditOptions = {},
): Promise<void> {
const stationIntervalM = options.stationIntervalM ?? 20;
const overlay = document.createElement("div");
overlay.className = "b05-routeedit";
overlay.innerHTML = `
@@ -87,7 +100,9 @@ export async function openRouteEditModal(
<strong> </strong>
<span class="b05-routeedit__hint">
= · = R · = ·
= · () = · =
= · <b> = </b> ·
<b>Shift+ = ·</b> ·
() = · =
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기"></button>
</div>
@@ -123,14 +138,13 @@ export async function openRouteEditModal(
/** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */
let planned: Vertex[] = [];
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
let nodeInfo: Array<{
radius_m: number | null;
inner_angle_deg: number | null;
violations: string[];
}> = [];
let nodeInfo: EditedNode[] = [];
let minRadiusM = 0;
/** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */
let limitRadiusM = 0;
let limitArcM = 0;
/** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */
let curveInfo: RoutePlanCurve[] = [];
let curveInfo: EditedCurve[] = [];
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
let curveOn: boolean[] = [];
let curveRadius: Array<number | null> = [];
@@ -143,7 +157,40 @@ export async function openRouteEditModal(
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
let history: RouteEditHistory | null = null;
let meta: VWorldMeta | null = null;
let sheets: PreparedLayer[] = [];
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */
let contours: RouteEditContours | null = null;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
let pickedContour = -1;
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
const crossPreview = createCrossPreview({
projectId,
bounds: () => canvas.getBoundingClientRect(),
request: () => ({
vertices: planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
min_radius_m: minRadiusM || 12,
station_interval_m: stationIntervalM,
}),
});
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
const measure = createMeasureTool({
projectId,
stationIntervalM,
line: () => (plannedLine.length ? plannedLine : planned),
// 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다.
toScreen: (vertex) => toScreen(vertex),
isClosed: () => closed,
onChange: () => {
status.textContent = `${routeHead()}${measure.hint()}`;
draw();
},
});
let view: ViewState = {
width: 0,
height: 0,
@@ -159,6 +206,7 @@ export async function openRouteEditModal(
window.removeEventListener("resize", resize);
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
crossPreview.destroy();
overlay.remove();
};
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
@@ -199,111 +247,40 @@ export async function openRouteEditModal(
return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)];
}
function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void {
if (points.length < 2) return;
context.save();
context.setLineDash(dash);
context.strokeStyle = color;
context.lineWidth = width;
context.beginPath();
points.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
context.restore();
/** 1m . `metricToScreen`
* 100m (1m ). */
function pxPerMeter(): number {
if (!meta) return 1;
const [x0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
const [x1] = metricToScreen(meta, view, meta.x_min + 100, meta.y_min);
return Math.abs(x1 - x0) / 100;
}
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
const contourStepM = (): number =>
contours ? pickLevelStep(contours.layer, view, contours.intervalM) : 0;
function draw(): void {
if (closed) return;
const style = getComputedStyle(document.documentElement);
context.clearRect(0, 0, view.width, view.height);
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
context.fillRect(0, 0, view.width, view.height);
context.save();
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
const band = meta
? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M)
: null;
if (band) {
context.beginPath();
context.rect(band.x, band.y, band.width, band.height);
context.clip();
}
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
context.lineWidth = 0.8;
for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot");
context.restore();
strokePolyline(
drawRouteEditScene(context, {
view,
toScreen,
pxPerMeter: pxPerMeter(),
hasMeta: meta !== null,
contours,
otherSheets,
pickedContour,
contourStepM: contourStepM(),
measure: measure.points(),
expected,
[6, 5],
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
1.6,
);
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
strokePolyline(
plannedLine.length ? plannedLine : planned,
[],
style.getPropertyValue("--map-route") || "#f97316",
2.4,
);
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.lineWidth = 1;
planned.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0;
context.fillStyle = bad
? style.getPropertyValue("--color-danger") || "#dc2626"
: style.getPropertyValue("--map-route") || "#f97316";
context.beginPath();
context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
context.fill();
context.stroke();
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) {
context.save();
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.beginPath();
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
context.fill();
context.restore();
}
plannedLine,
planned,
nodeInfo,
curveInfo,
curveOn,
picked,
stationIntervalM,
});
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
context.lineWidth = 2;
curveInfo.forEach((curve) => {
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
const on = curveOn[curve.node_first] !== false;
if (!on) return; // 곡선을 지운 자리에는 접선점도 없다.
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
const isPicked = curve.node_first === picked;
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
context.rect(x - size, y - size, size * 2, size * 2);
context.fillStyle = isPicked
? style.getPropertyValue("--map-route") || "#f97316"
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
syncCurveBar();
}
@@ -346,12 +323,27 @@ export async function openRouteEditModal(
function markEdited(): void {
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
applyArcLocks(planned, curveLock, curveArc, curveRadius);
// 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④).
applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM);
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
plannedLine = built.vertices;
curveInfo = built.curves;
nodeInfo = built.nodes;
}
/** ( 0-9 ).
* . */
const routeHead = (): string =>
`길이 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` +
`노드 ${planned.length}`;
/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */
const contourHint = (): string => {
if (pickedContour < 0) return "등고선을 누르면 그 줄의 높이가 보입니다.";
const level = contours?.layer.features[pickedContour]?.labelValue ?? null;
return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`;
};
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
const curveHint = (): string =>
curveSummary({
@@ -365,84 +357,32 @@ export async function openRouteEditModal(
fresh: nodeInfo.length === 0,
});
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
const curveLabelBox = createCurveLabel({
onRadius: (value) => {
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
},
onArcLength: (value) => {
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
},
onLock: (lock) => {
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onCurveOn: (on) => {
if (picked < 0) return;
curveOn[picked] = on;
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
const curveBar = createCurveBar({
canvas,
state: () => ({
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
curveArc,
limitRadiusM,
limitArcM,
}),
toScreen: (vertex) => toScreen(vertex),
applyEdit: (message) => applyEdit(message),
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
function syncCurveBar(): void {
if (!(picked > 0 && picked < planned.length - 1)) {
curveLabelBox.hide();
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const rect = canvas.getBoundingClientRect();
const [screenX, screenY] = toScreen(planned[picked]);
curveLabelBox.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다.
at: [screenX + rect.left, screenY + rect.top],
centerDirection: pickedCurve
? centerDirectionOf(
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
toScreen(pickedCurve.start),
toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
});
}
const curveLabelBox = curveBar.label;
const syncCurveBar = curveBar.sync;
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
function applyEdit(message: string, record = true): void {
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length}${message} ${curveHint()}`;
status.textContent = `${routeHead()}${message} ${curveHint()}`;
draw();
if (record) history?.commit(snapshotNow());
historyControls.sync();
@@ -481,6 +421,11 @@ export async function openRouteEditModal(
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
if (event.shiftKey) {
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
void measure.pick(px, py);
return;
}
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
@@ -495,6 +440,32 @@ export async function openRouteEditModal(
picked = dragHandle.node;
syncCurveBar();
draw();
} else if (
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
(() => {
const chainage = stationAtScreen(
plannedLine.length ? plannedLine : planned,
toScreen,
stationIntervalM,
px,
py,
STATION_HIT_PX,
);
if (chainage === null) return false;
void crossPreview.open(chainage);
return true;
})()
) {
/* 횡단 창이 떴다 — 더 집지 않는다. */
} else if (contours) {
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
const hit = hitPreparedLayer(contours.layer, view, px, py, CONTOUR_HIT_PX, contourStepM());
if (hit !== pickedContour) {
pickedContour = hit;
status.textContent = `${routeHead()}${contourHint()} ${curveHint()}`;
draw();
}
}
canvas.setPointerCapture(event.pointerId);
});
@@ -509,7 +480,8 @@ export async function openRouteEditModal(
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
if (moved) {
planned[node] = moved.apex;
curveRadius[node] = Math.round(moved.radius * 100) / 100;
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
curveRadius[node] = Math.max(limitRadiusM, Math.round(moved.radius * 100) / 100);
curveOn[node] = true;
picked = node;
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
@@ -517,18 +489,32 @@ export async function openRouteEditModal(
dragMoved = true;
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length} — 곡선을 잡는 중. ${curveHint()}`;
status.textContent = `${routeHead()} — 곡선을 잡는 중. ${curveHint()}`;
draw();
}
return;
}
if (dragNode >= 0) {
// 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM);
const previous = planned[dragNode];
dragMoved = true;
planned[dragNode] = toMetric(px, py);
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) {
// 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다.
planned[dragNode] = previous;
markEdited();
status.textContent =
`${routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`;
draw();
return;
}
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
status.textContent = `노드 ${planned.length} — 옮기는 중. ${curveHint()}`;
status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`;
draw();
return;
}
@@ -595,54 +581,13 @@ export async function openRouteEditModal(
draw,
});
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
busy.hidden = false;
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
// 87.3 · 90.0 · 93.9 · 95.4초). 중간 취소를 안 만드는 대신, **얼마나 지났는지**를
// 보여 사람이 멈춘 것인지 도는 것인지 알 수 있게 한다(계획서 0-2).
const message = busy.querySelector("span")!;
const started = Date.now();
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
};
tick();
const timer = window.setInterval(tick, 1000);
try {
await task();
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
clearDrafts(projectId);
clearResults(projectId);
showToast("노선을 다시 계산했습니다.", "success");
close();
await onApplied();
} catch (error) {
busy.hidden = true;
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
} finally {
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
}
}
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
if (planned.length < 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
void runHeavy("계획노선 반영", () =>
replanRoute(
projectId,
planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
),
);
});
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
bindRouteApply({
overlay,
busy,
projectId,
nodes: () => ({ planned, curveOn, curveRadius }),
close,
onApplied,
});
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
@@ -658,6 +603,8 @@ export async function openRouteEditModal(
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
const nodes = plan.nodes ?? [];
minRadiusM = plan.min_radius_m ?? 0;
limitRadiusM = plan.limit_radius_m ?? 0;
limitArcM = plan.limit_curve_length_m ?? 0;
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = flattenServerPlan(nodes, plan.curves ?? []);
planned = flat.planned;
@@ -675,9 +622,23 @@ export async function openRouteEditModal(
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
const normalizer = createNormalizer(drainage.meta);
sheets = drainage.layers
// 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
otherSheets = drainage.layers
.filter(([layer]) => layer !== "도엽_등고선")
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
.filter((layer): layer is PreparedLayer => layer !== null);
contours = await loadRouteEditContours(
projectId,
drainage.meta,
normalizer,
drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null,
{
surfaceModelId: options.surfaceModelId ?? null,
intervalM: options.contourIntervalM ?? 1,
smooth: options.smooth ?? false,
},
);
if (closed) return;
resize();
const xs = planned.map((vertex) => vertex[0]);
const ys = planned.map((vertex) => vertex[1]);
@@ -694,7 +655,8 @@ export async function openRouteEditModal(
);
view = { ...view, ...fitted };
status.textContent =
`노드 ${planned.length} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
`${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` +
curveHint();
draw();
} catch (error) {
@@ -0,0 +1,81 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Apply.ts
* **[]·[]** .
*
* ·· ( 90).
* ( 0-2, 2026-09-09) ** **
* .
* ========================================================================== */
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
type Vertex = [number, number];
export interface RouteApplyParams {
overlay: HTMLElement;
/** 화면 전체를 덮는 대기 막. 안에 `<span>` 한 개가 글을 받는다. */
busy: HTMLElement;
projectId: string;
/** 지금 편집값 — 누른 순간에 읽는다. */
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
close: () => void;
onApplied: () => void | Promise<void>;
}
/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */
export function bindRouteApply(params: RouteApplyParams): void {
const { overlay, busy, projectId } = params;
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
busy.hidden = false;
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
// 87.3 · 90.0 · 93.9 · 95.4초).
const message = busy.querySelector("span")!;
const started = Date.now();
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
};
tick();
const timer = window.setInterval(tick, 1000);
try {
await task();
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
clearDrafts(projectId);
clearResults(projectId);
showToast("노선을 다시 계산했습니다.", "success");
params.close();
await params.onApplied();
} catch (error) {
busy.hidden = true;
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
} finally {
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
}
}
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
const { planned, curveOn, curveRadius } = params.nodes();
if (planned.length < 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
void runHeavy("계획노선 반영", () =>
replanRoute(
projectId,
planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
),
);
});
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
});
}
@@ -0,0 +1,97 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Contour.ts
* ** ** .
*
* ** **(2026-09-12 ) LAS
* . ** LAS **
* , .
*
* GeoJSON( `등고수치` ), LAS
* (m) ( `level`). ** `PreparedLayer` **
* ·· .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
import {
type GeoJsonCollection,
type Normalizer,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
prepareLayer,
prepareMetricPolylines,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
/** 도엽 등고선의 표고 속성 이름 — B04 지도가 쓰는 것과 같은 키. */
const SHEET_ELEVATION_KEYS = ["등고수치"];
/** 표고를 못 읽었을 때 라벨 솎기에 쓸 간격(m). */
const FALLBACK_INTERVAL_M = 5;
export interface RouteEditContours {
layer: PreparedLayer;
/** 등고선 간격(m) — 라벨을 몇 줄마다 낼지 정하는 기준. */
intervalM: number;
source: "las" | "sheet";
}
interface ContourResponse {
contours: Array<{ level: number; coordinates: Array<[number, number, number]> }>;
}
/**
* . LAS, .
*
* LAS ** ** ,
* `source` .
*/
export async function loadRouteEditContours(
projectId: string,
meta: VWorldMeta,
normalizer: Normalizer,
sheet: GeoJsonCollection | null,
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
): Promise<RouteEditContours> {
if (options.surfaceModelId !== null) {
const interval = options.intervalM > 0 ? options.intervalM : 1;
try {
// 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다.
const data = await fetchCachedJson<ContourResponse>(
projectId,
`${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` +
`/contour?interval=${interval}&smooth=${options.smooth}`,
);
const lines = (data.contours ?? [])
.map((contour) => ({
points: contour.coordinates.map(([x, y]) => [x, y] as const),
label: contour.level,
}))
.filter((line) => line.points.length >= 2);
if (lines.length > 0) {
return { layer: prepareMetricPolylines(lines, meta), intervalM: interval, source: "las" };
}
} catch {
/* 내려앉는다 — 아래 도엽 갈래로 이어 간다. */
}
}
const layer = prepareLayer(sheet ?? undefined, normalizer, SHEET_ELEVATION_KEYS);
return { layer, intervalM: inferIntervalM(layer), source: "sheet" };
}
/** 도엽 등고선의 간격(m) — 표고 값들의 **가장 좁은 칸**을 간격으로 본다. */
function inferIntervalM(layer: PreparedLayer): number {
const levels = [
...new Set(
layer.features
.map((feature) => feature.labelValue)
.filter((value): value is number => value !== null),
),
].sort((a, b) => a - b);
let smallest = Infinity;
for (let index = 1; index < levels.length; index += 1) {
const gap = levels[index] - levels[index - 1];
if (gap > 0 && gap < smallest) smallest = gap;
}
return Number.isFinite(smallest) ? smallest : FALLBACK_INTERVAL_M;
}
@@ -0,0 +1,234 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross.ts
* ** ** ( 0-9 ).
*
* (2026-09-12 ) ** · ·
* **. .
*
* ** ** [] .
* ( ) .
* , .
*
* **B05·B06 ** · `generate_sections`,
* `compute_cross_design`(), B06 `fillSlopeLengths`().
* ========================================================================== */
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
/** 그림 가장자리 여백(px). */
const PAD = 28;
export interface CrossPreviewParams {
projectId: string;
/** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */
bounds: () => DOMRect;
/** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */
request: () => {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
min_radius_m: number;
station_interval_m: number;
};
}
export interface CrossPreviewWindow {
/** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */
open: (chainageM: number) => Promise<void>;
/** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
destroy: () => void;
}
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
const root = document.createElement("div");
root.className = "b05-routeedit__cross";
root.hidden = true;
root.innerHTML = `
<div class="b05-routeedit__cross-head">
<strong class="b05-routeedit__cross-title"> </strong>
<button type="button" class="b05-routeedit__cross-close" aria-label="닫기"></button>
</div>
<canvas class="b05-routeedit__cross-canvas" width="420" height="260"></canvas>
<div class="b05-routeedit__cross-foot"></div>`;
document.body.append(root);
const head = root.querySelector<HTMLElement>(".b05-routeedit__cross-head")!;
const title = root.querySelector<HTMLElement>(".b05-routeedit__cross-title")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!;
const context = canvas.getContext("2d")!;
root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => {
root.hidden = true;
});
// 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다.
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
root.addEventListener(type, (event) => event.stopPropagation());
}
// ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ──
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
head.addEventListener("pointerdown", (event) => {
if ((event.target as HTMLElement).closest("button")) return;
dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop };
head.setPointerCapture(event.pointerId);
event.preventDefault();
});
head.addEventListener("pointermove", (event) => {
if (!dragFrom) return;
root.style.left = `${Math.round(dragFrom.left + event.clientX - dragFrom.x)}px`;
root.style.top = `${Math.round(dragFrom.top + event.clientY - dragFrom.y)}px`;
});
const stopDrag = (event: PointerEvent): void => {
if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId);
dragFrom = null;
};
head.addEventListener("pointerup", stopDrag);
head.addEventListener("pointercancel", stopDrag);
/** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */
let asked = -1;
return {
async open(chainageM) {
asked = chainageM;
root.hidden = false;
if (!root.style.left) {
// 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다.
// 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다.
const box = params.bounds();
root.style.left = `${Math.round(box.right - root.offsetWidth - 16)}px`;
root.style.top = `${Math.round(box.bottom - root.offsetHeight - 16)}px`;
}
title.textContent = "횡단 미리보기 — 읽는 중…";
foot.textContent = "";
context.clearRect(0, 0, canvas.width, canvas.height);
let preview: CrossPreviewResponse;
try {
preview = await fetchCrossPreview(params.projectId, {
...params.request(),
chainage_m: chainageM,
});
} catch (error) {
if (asked !== chainageM) return;
title.textContent = "횡단 미리보기";
foot.textContent = error instanceof Error ? error.message : "횡단을 읽지 못했습니다.";
return;
}
if (asked !== chainageM || root.hidden) return;
title.textContent = `횡단 미리보기 — ${preview.label ?? `${preview.chainage_m}m`}`;
drawCross(context, canvas, preview);
foot.textContent = summarize(preview);
},
destroy() {
root.remove();
},
};
}
/** 성토사면 길이·절성토 면적 한 줄. 계획고가 없다는 것도 여기 적는다. */
function summarize(preview: CrossPreviewResponse): string {
const design = preview.design;
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
const lengths = fillSlopeLengths({
samples: preview.samples,
design,
} as unknown as CrossSection);
const sides = (["left", "right"] as const)
.filter((side) => lengths[side] !== null)
.map((side) => {
const value = lengths[side]!;
const label = side === "left" ? "좌" : "우";
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
return `${label} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
});
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
return (
`${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}` +
" · 계획고는 [확인] 뒤에 정해지므로 지반을 따라 세운 기본 계획임"
);
}
/** 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+)가 왼쪽에 오게 눕힌다. */
function drawCross(
context: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
preview: CrossPreviewResponse,
): void {
const ground = preview.samples
.filter((sample) => sample.valid && sample.elevation_m !== null)
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]);
const design = (preview.design?.design_line ?? []).map(
(point) => [point.offset_m, point.elevation_m] as [number, number],
);
const all = [...ground, ...design];
context.clearRect(0, 0, canvas.width, canvas.height);
if (all.length < 2) return;
const offsets = all.map((point) => point[0]);
const heights = all.map((point) => point[1]);
const minOffset = Math.min(...offsets);
const maxOffset = Math.max(...offsets);
const minZ = Math.min(...heights);
const maxZ = Math.max(...heights);
const spanX = maxOffset - minOffset || 1;
const spanZ = maxZ - minZ || 1;
// **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
// 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ);
const centerOffset = (minOffset + maxOffset) / 2;
const centerZ = (minZ + maxZ) / 2;
// 좌(+offset)가 화면 왼쪽 — 횡단도 규약(generate_sections cad_exchange)과 같은 방향이다.
const toScreen = (point: [number, number]): [number, number] => [
canvas.width / 2 + (centerOffset - point[0]) * scale,
canvas.height / 2 + (centerZ - point[1]) * scale,
];
const stroke = (points: Array<[number, number]>, color: string, width: number): void => {
if (points.length < 2) return;
context.beginPath();
points.forEach((point, index) => {
const [x, y] = toScreen(point);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.strokeStyle = color;
context.lineWidth = width;
context.stroke();
};
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
const [centerX] = toScreen([0, centerZ]);
context.save();
context.setLineDash([4, 4]);
context.strokeStyle = "rgba(148,163,184,0.7)";
context.lineWidth = 1;
context.beginPath();
context.moveTo(centerX, PAD / 2);
context.lineTo(centerX, canvas.height - PAD / 2);
context.stroke();
context.restore();
stroke(ground, "#94a3b8", 1.6); // 원지반
stroke(design, "#f97316", 2.2); // 기본 계획 횡단
context.font = "11px system-ui, sans-serif";
context.textBaseline = "top";
context.fillStyle = "#94a3b8";
context.textAlign = "left";
context.fillText("원지반", PAD, 6);
context.fillStyle = "#f97316";
context.textAlign = "right";
context.fillText("기본 계획 횡단", canvas.width - PAD, 6);
context.fillStyle = "#94a3b8";
context.textAlign = "center";
context.textBaseline = "bottom";
context.fillText(
`${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
canvas.width / 2,
canvas.height - 4,
);
}
@@ -0,0 +1,152 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_CurveBar.ts
* **** ,
* . `_Label` .
*
* `B05_Profile_UI_RouteEdit.ts` 700 (2026-09-12).
* , `state()` .
*
* **R **(L = R·Δ) ** **
* . (`_Edits.ts` ).
* ========================================================================== */
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
import {
centerDirectionOf,
createCurveLabel,
deflectionRad,
type CurveLabel,
} from "./B05_Profile_UI_RouteEdit_Label";
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
export interface CurveBarState {
picked: number;
planned: Vertex[];
nodeInfo: EditedNode[];
curveInfo: EditedCurve[];
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM: number;
limitArcM: number;
}
export interface CurveBarParams {
canvas: HTMLCanvasElement;
state: () => CurveBarState;
toScreen: (vertex: Vertex) => [number, number];
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
applyEdit: (message: string) => void;
}
export interface CurveBar {
label: CurveLabel;
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
sync: () => void;
}
export function createCurveBar(params: CurveBarParams): CurveBar {
const label = createCurveLabel({
onRadius: (value) => {
const { picked, curveRadius } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
params.applyEdit(
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
params.applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
function sync(): void {
const {
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
limitRadiusM,
limitArcM,
} = params.state();
if (!(picked > 0 && picked < planned.length - 1)) {
label.hide();
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const rect = params.canvas.getBoundingClientRect();
const [screenX, screenY] = params.toScreen(planned[picked]);
label.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
bounds: {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
},
centerDirection: pickedCurve
? centerDirectionOf(
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
params.toScreen(pickedCurve.start),
params.toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
limitRadiusM,
limitArcM,
});
}
return { label, sync };
}
@@ -48,6 +48,75 @@ export function applyArcLocks(
}
}
/**
* ** **( 0-9 , 2026-09-12 ).
*
* L = R·Δ L/Δ .
* . ** () **
* , R .
*/
export function applyCurveLimits(
planned: Vertex[],
curveOn: ReadonlyArray<boolean>,
curveRadius: Array<number | null>,
limitRadiusM: number,
limitArcM: number,
): void {
if (limitRadiusM <= 0 && limitArcM <= 0) return;
for (let seat = 1; seat < planned.length - 1; seat += 1) {
if (curveOn[seat] === false) continue;
const current = curveRadius[seat];
if (current === null || current === undefined) continue;
const deflection = deflectionRad(
innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]),
);
const byArc = limitArcM > 0 && deflection > 1e-9 ? limitArcM / deflection : 0;
const floor = Math.max(limitRadiusM, byArc);
if (floor > 0 && current < floor) curveRadius[seat] = floor;
}
}
/**
* ** **(m). 0.
*
* R .
* , ** **
* (2026-09-12).
*
* . **
* ** ( 1 2
* ). .
*/
export function curveShortfalls(
nodes: ReadonlyArray<EditedNode>,
limitRadiusM: number,
limitArcM: number,
): number[] {
return nodes.map((node) => {
if (node.radius_m === null || (limitRadiusM <= 0 && limitArcM <= 0)) return 0;
let short = 0;
if (limitRadiusM > 0) short = Math.max(short, limitRadiusM - node.radius_m);
if (limitArcM > 0) {
short = Math.max(short, limitArcM - node.radius_m * deflectionRad(node.inner_angle_deg));
}
return Math.max(0, short);
});
}
/**
* ** **. (·)
* .
*
* ** **
* 1px (2026-09-12
* ). ( ), **
* ** .
*/
export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean {
if (before.length !== after.length) return false;
return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6);
}
export interface CurveSummaryInput {
nodeCount: number;
curveOn: boolean[];
@@ -181,6 +181,98 @@ export function segmentAtScreen(
return best;
}
/** 노선 위 한 점 — 어디를 짚었나와 그 자리의 누가거리. */
export interface RoutePointHit {
/** 사업지 좌표(m). */
point: [number, number];
/** 시점에서 노선을 따라간 거리(m). */
chainageM: number;
}
/**
* ( ) ** ** . null.
*
* · ( 0-9 )
* . .
*/
export function routePointAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
px: number,
py: number,
maxPx: number,
): RoutePointHit | null {
let best: RoutePointHit | null = null;
let bestDistance = maxPx;
let travelled = 0;
for (let index = 0; index < line.length - 1; index += 1) {
const from = line[index];
const to = line[index + 1];
const segmentM = Math.hypot(to[0] - from[0], to[1] - from[1]);
const [ax, ay] = toScreen(from);
const [bx, by] = toScreen(to);
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
if (distance < bestDistance) {
bestDistance = distance;
best = {
point: [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t],
chainageM: travelled + segmentM * t,
};
}
travelled += segmentM;
}
return best;
}
/**
* ** ** (m). null( 0-9 ).
*
* `drawStationTicks` ** **
* . .
*/
export function stationAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
intervalM: number,
px: number,
py: number,
maxPx: number,
): number | null {
if (line.length < 2 || !(intervalM > 0)) return null;
const cumulative: number[] = [0];
for (let index = 1; index < line.length; index += 1) {
cumulative.push(
cumulative[index - 1] +
Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]),
);
}
const total = cumulative[cumulative.length - 1];
let best: number | null = null;
let bestDistance = maxPx;
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += intervalM) {
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = line[cursor - 1];
const front = line[cursor];
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
const [x, y] = toScreen([
back[0] + (front[0] - back[0]) * ratio,
back[1] + (front[1] - back[1]) * ratio,
]);
const distance = Math.hypot(x - px, y - py);
if (distance < bestDistance) {
bestDistance = distance;
best = chainage;
}
}
return best;
}
/** `bandM` . null.
*
* ** ** ·· .
+70 -11
View File
@@ -11,7 +11,10 @@
*
* ****(2026-09-07 )
* · `document.body` `position: fixed` `overflow: hidden`
* ****. .
* ****.
* · ** **(2026-09-12 )
* .
* .
* · ** **, **16** (4 ).
* · ** **. ,
* .
@@ -60,12 +63,17 @@ export interface CurveLabelState {
at: [number, number];
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */
centerDirection: [number, number] | null;
/** 패널이 넘어가면 안 되는 테두리(화면 좌표) — 보통 모달의 지도 칸. 없으면 안 가둔다. */
bounds?: { left: number; top: number; right: number; bottom: number };
curveOn: boolean;
radiusShown: number | null;
/** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
arcLengthShown: number | null;
lock: CurveLock;
innerAngleDeg: number | null;
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM?: number;
limitArcM?: number;
}
export interface CurveLabelHandlers {
@@ -143,13 +151,25 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
let manual: [number, number] | null = null;
let anchor: [number, number] = [0, 0];
/** 지금 자리의 하한 — 칸이 여기서 멈춘다. 0이면 제한 없음. */
let limitRadius = 0;
let limitArc = 0;
/** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */
let limit: CurveLabelState["bounds"];
const numberOf = (input: HTMLInputElement): number | null => {
/** . ** , **
* (2026-09-12 ) . */
const numberOf = (input: HTMLInputElement, floor: number): number | null => {
const value = Number(input.value);
return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null;
if (input.value.trim() === "" || !Number.isFinite(value) || value <= 0) return null;
if (floor > 0 && value < floor) {
input.value = String(floor);
return floor;
}
return value;
};
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius)));
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc)));
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius)));
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc)));
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
@@ -169,8 +189,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
});
head.addEventListener("pointermove", (event) => {
if (!dragFrom) return;
const left = dragFrom.left + event.clientX - dragFrom.x;
const top = dragFrom.top + event.clientY - dragFrom.y;
// 끄는 동안에도 테두리를 지킨다 — 놓은 뒤에만 가두면 손이 간 자리에서 패널이 튄다.
const [left, top] = clamp(
dragFrom.left + event.clientX - dragFrom.x,
dragFrom.top + event.clientY - dragFrom.y,
root.offsetWidth,
root.offsetHeight,
limit,
);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
// 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다.
@@ -183,7 +209,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
head.addEventListener("pointerup", stopDrag);
head.addEventListener("pointercancel", stopDrag);
/** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. */
/** , 16. .
* ** ** . */
function place(state: CurveLabelState): void {
const width = root.offsetWidth;
const height = root.offsetHeight;
@@ -193,12 +220,32 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
: ([1, 0] as [number, number]);
const distance = GAP_PX + boxReach(away[0], away[1], width, height);
anchor = [nx + away[0] * distance - width / 2, ny + away[1] * distance - height / 2];
const left = anchor[0] + (manual ? manual[0] : 0);
const top = anchor[1] + (manual ? manual[1] : 0);
const [left, top] = clamp(
anchor[0] + (manual ? manual[0] : 0),
anchor[1] + (manual ? manual[1] : 0),
width,
height,
state.bounds,
);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
}
/** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */
function clamp(
left: number,
top: number,
width: number,
height: number,
bounds: CurveLabelState["bounds"],
): [number, number] {
if (!bounds) return [left, top];
return [
Math.max(bounds.left, Math.min(left, bounds.right - width)),
Math.max(bounds.top, Math.min(top, bounds.bottom - height)),
];
}
return {
show(state) {
if (state.seat !== seat) {
@@ -207,6 +254,12 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
}
curveOn = state.curveOn;
lock = state.lock;
limit = state.bounds;
limitRadius = state.limitRadiusM ?? 0;
limitArc = state.limitArcM ?? 0;
// 칸 자체에도 하한을 박아 화살표·스피너가 그 아래로 안 내려가게 한다.
radius.min = limitRadius > 0 ? String(limitRadius) : "1";
arc.min = limitArc > 0 ? String(limitArc) : "1";
root.hidden = false;
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
@@ -223,8 +276,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
const inner = state.innerAngleDeg;
const held =
lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음";
const floors = [
limitRadius > 0 ? `R ≥ ${limitRadius}m` : "",
limitArc > 0 ? `L ≥ ${limitArc}m` : "",
]
.filter(Boolean)
.join(" · ");
info.textContent = state.curveOn
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}`
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}${floors ? ` · ${floors}` : ""}`
: "곡선 없음 — 직선이 그대로 꺾입니다";
place(state);
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
@@ -0,0 +1,104 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Measure.ts
* ** ** ( 0-9 ).
*
* Shift+ a·b . ·
* , .
*
* ** ** (`/route/elevations`).
* ( 0-2 7)
* .
* ========================================================================== */
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
import { formatStation } from "./B05_Profile_Util_Station";
type Vertex = [number, number];
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
const MEASURE_HIT_PX = 14;
interface MeasurePoint extends RoutePointHit {
/** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */
z: number | null;
}
export interface MeasureToolParams {
projectId: string;
/** 규칙 측점 간격(m) — 측점 표기에 쓴다. */
stationIntervalM: number;
/** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
line: () => Vertex[];
toScreen: (vertex: Vertex) => [number, number];
/** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */
isClosed: () => boolean;
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
onChange: () => void;
}
export interface MeasureTool {
/** 찍힌 자리(0~2개) — 그리기가 쓴다. */
points: () => Vertex[];
/** 상태줄에 낼 한 줄. */
hint: () => string;
/** Shift+클릭 한 번. 두 점이 차면 지반고를 한 번만 물어 온다. */
pick: (px: number, py: number) => Promise<void>;
}
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
let picked: MeasurePoint[] = [];
const hint = (): string => {
if (picked.length === 0) return "Shift+클릭으로 두 점을 찍으면 거리와 기울기가 보입니다.";
const first = picked[0];
if (picked.length === 1) {
return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
}
const second = picked[1];
const span = Math.abs(second.chainageM - first.chainageM);
const head =
`구간 ${formatStation(first.chainageM, params.stationIntervalM)}` +
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
if (first.z === null || second.z === null || span <= 1e-6) {
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
}
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
const rise = second.z - first.z;
return (
`${head} · 지반고 ${first.z.toFixed(1)}${second.z.toFixed(1)}m` +
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
);
};
return {
points: () => picked.map((entry) => entry.point),
hint,
async pick(px, py) {
const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX);
if (!hit) {
picked = []; // 노선을 빗나가면 재던 것을 접는다.
params.onChange();
return;
}
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
params.onChange();
if (picked.length < 2) return;
const asked = picked;
try {
const heights = await fetchRouteElevations(
params.projectId,
asked.map((entry) => entry.point),
);
if (params.isClosed() || picked !== asked) return; // 그 사이 다시 찍었으면 버린다.
asked.forEach((entry, index) => {
entry.z = heights[index] ?? null;
});
} catch {
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
}
params.onChange();
},
};
}
@@ -0,0 +1,353 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Render.ts
* **** ···· ,
* ·· .
*
* `B05_Profile_UI_RouteEdit.ts` 700 (2026-09-12).
* , `scene` .
*
* **B04 · **(`drawStationTicks`)
* ( 0-9 ).
* ========================================================================== */
import {
drawPreparedFeature,
drawPreparedLabels,
drawPreparedLayer,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import { formatStation } from "./B05_Profile_Util_Station";
/** 노드 반지름(px). */
const NODE_R = 4;
/** ** **(m) (2026-09-07).
*
* . ** **
* ( ).
* `drawPreparedLayer` . */
const CONTOUR_BAND_M = 300;
/** · (px) ** ** .
* 3.5px (2026-09-07 ). */
const CURVE_HANDLE_PX = 5;
/** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */
const OUTWARD_PX = 26;
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
const MEASURE_COLOR = "#22c55e";
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
let total = 0;
for (let index = 1; index < points.length; index += 1) {
total += Math.hypot(
points[index][0] - points[index - 1][0],
points[index][1] - points[index - 1][1],
);
}
return total;
}
export interface RouteEditScene {
view: ViewState;
/** 사업지 좌표(m) → 캔버스 px. */
toScreen: (vertex: Vertex) => [number, number];
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 이 값으로 정한다. */
pxPerMeter: number;
/** 도엽 메타를 읽었나 — 못 읽었으면 등고선 띠를 씌우지 않는다. */
hasMeta: boolean;
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */
contours: RouteEditContours | null;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
otherSheets: ReadonlyArray<PreparedLayer>;
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
pickedContour: number;
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 **같은 값**을 봐야 한다. */
contourStepM: number;
expected: ReadonlyArray<Vertex>;
/** 그려 보이는 계획노선(원호 포함). */
plannedLine: ReadonlyArray<Vertex>;
/** 잡아 옮기는 노드(꺾임점). */
planned: ReadonlyArray<Vertex>;
nodeInfo: ReadonlyArray<EditedNode>;
curveInfo: ReadonlyArray<EditedCurve>;
curveOn: ReadonlyArray<boolean>;
/** 지금 고른 꺾임점. 없으면 -1. */
picked: number;
/** 규칙 측점 간격(m). */
stationIntervalM: number;
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리(계획서 0-9 ⑤). */
measure: ReadonlyArray<Vertex>;
}
export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void {
const { view, toScreen } = scene;
const style = getComputedStyle(document.documentElement);
const line = scene.plannedLine.length ? scene.plannedLine : scene.planned;
context.clearRect(0, 0, view.width, view.height);
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
context.fillRect(0, 0, view.width, view.height);
context.save();
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
const band = scene.hasMeta ? contourBandRect(line as Vertex[], toScreen, CONTOUR_BAND_M) : null;
if (band) {
context.beginPath();
context.rect(band.x, band.y, band.width, band.height);
context.clip();
}
context.strokeStyle = style.getPropertyValue("--map-sheet-stream") || "#2563eb";
context.lineWidth = 1.2;
for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot");
if (scene.contours) {
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
const everyM = scene.contourStepM;
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
context.lineWidth = 0.8;
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
if (scene.pickedContour >= 0) {
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
context.lineWidth = 2.6;
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
}
// 등고 높이값 — 확대가 클수록 촘촘히 낸다(계획서 0-9 ③).
context.font = "10px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
drawPreparedLabels(
context,
scene.contours.layer,
view,
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
everyM,
);
}
context.restore();
strokePolyline(
context,
toScreen,
scene.expected,
[6, 5],
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
1.6,
);
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
strokePolyline(
context,
toScreen,
line,
[],
style.getPropertyValue("--map-route") || "#f97316",
2.4,
);
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.lineWidth = 1;
scene.planned.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0;
context.fillStyle = bad
? style.getPropertyValue("--color-danger") || "#dc2626"
: style.getPropertyValue("--map-route") || "#f97316";
context.beginPath();
context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
context.fill();
context.stroke();
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
if (
scene.curveOn.length &&
!scene.curveOn[index] &&
index > 0 &&
index < scene.planned.length - 1
) {
context.save();
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.beginPath();
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
context.fill();
context.restore();
}
});
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
context.lineWidth = 2;
scene.curveInfo.forEach((curve) => {
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
const isPicked = curve.node_first === scene.picked;
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
context.rect(x - size, y - size, size * 2, size * 2);
context.fillStyle = isPicked
? style.getPropertyValue("--map-route") || "#f97316"
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
drawStationMarks(context, scene, line);
drawMeasureMarks(context, scene, line);
}
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
function drawMeasureMarks(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
if (scene.measure.length === 0) return;
context.save();
if (scene.measure.length >= 2) {
const span = spanBetween(line, scene.measure[0], scene.measure[1]);
if (span.length >= 2) {
context.strokeStyle = MEASURE_COLOR;
context.lineWidth = 4;
context.beginPath();
span.forEach((vertex, index) => {
const [x, y] = scene.toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
}
}
context.lineWidth = 2.4;
context.strokeStyle = MEASURE_COLOR;
context.font = "bold 11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
scene.measure.forEach((vertex, index) => {
const [x, y] = scene.toScreen(vertex);
context.fillStyle = "rgba(255,255,255,0.95)";
context.beginPath();
context.arc(x, y, 7, 0, Math.PI * 2);
context.fill();
context.stroke();
context.fillStyle = "#14532d";
context.fillText(index === 0 ? "a" : "b", x, y);
});
context.restore();
}
/** 두 점 사이의 노선 조각 — 가장 가까운 정점부터 정점까지. 어디를 쟀는지 보이기만 하면 된다. */
function spanBetween(line: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] {
const nearest = (target: Vertex): number => {
let best = 0;
let bestDistance = Infinity;
line.forEach((vertex, index) => {
const distance = Math.hypot(vertex[0] - target[0], vertex[1] - target[1]);
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
});
return best;
};
const start = nearest(from);
const end = nearest(to);
const [low, high] = start <= end ? [start, end] : [end, start];
return [from, ...line.slice(low, high + 1), to];
}
/** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */
function drawStationMarks(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
if (line.length < 2) return;
// 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게.
drawStationTicks(
context,
line.map(([x, y]) => ({ x, y })),
{
intervalM: scene.stationIntervalM,
pxPerMeter: scene.pxPerMeter,
toScreen: (x, y) => scene.toScreen([x, y]),
},
);
const total = polylineLengthM(line);
const last = line.length - 1;
endLabel(context, scene, line[0], line[1], "시점 0+0.0");
endLabel(
context,
scene,
line[last],
line[last - 1],
`종점 ${formatStation(total, scene.stationIntervalM)}`,
);
}
/** · .
*
* ** **( ).
* (0+0.0 · 50+0.0) ****
* (2026-09-12 ). */
function endLabel(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
at: Vertex,
inward: Vertex,
text: string,
): void {
const [x0, y0] = scene.toScreen(at);
const [x1, y1] = scene.toScreen(inward);
const length = Math.hypot(x0 - x1, y0 - y1) || 1;
const x = x0 + ((x0 - x1) / length) * OUTWARD_PX;
const y = y0 + ((y0 - y1) / length) * OUTWARD_PX;
context.save();
context.font = "bold 12px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
const width = context.measureText(text).width + 10;
context.fillStyle = "rgba(255, 255, 255, 0.9)";
context.fillRect(x - width / 2, y - 26, width, 17);
context.strokeStyle = "#f97316";
context.lineWidth = 1;
context.strokeRect(x - width / 2, y - 26, width, 17);
context.fillStyle = "#111111";
context.fillText(text, x, y - 17.5);
context.restore();
}
function strokePolyline(
context: CanvasRenderingContext2D,
toScreen: (vertex: Vertex) => [number, number],
points: ReadonlyArray<Vertex>,
dash: number[],
color: string,
width: number,
): void {
if (points.length < 2) return;
context.save();
context.setLineDash(dash);
context.strokeStyle = color;
context.lineWidth = width;
context.beginPath();
points.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
context.restore();
}
@@ -241,3 +241,53 @@
color: var(--color-text-secondary);
line-height: 1.35;
}
/* 측점 횡단 미리보기 (계획서 0-9 )
곡선 조작 패널과 같은 까닭으로 `document.body` 띄운다 모달이 `overflow: hidden`
이라 안에 두면 가장자리에서 잘린다. 머리를 잡아 옮길 있다. */
.b05-routeedit__cross {
position: fixed;
z-index: calc(var(--z-modal, 1000) + 2);
display: flex;
flex-direction: column;
gap: var(--spacing-8, 8px);
width: 452px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface-raised);
box-shadow: 0 8px 28px rgb(0 0 0 / 40%);
}
.b05-routeedit__cross-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8, 8px);
cursor: move;
touch-action: none;
}
.b05-routeedit__cross-close {
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
}
.b05-routeedit__cross-canvas {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
}
.b05-routeedit__cross-foot {
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.5;
}
@@ -157,9 +157,10 @@ export function renderConversionFactorFields(
if (!entries.length) return null;
const box = document.createElement("div");
box.className = "b08-quantity__factors";
// 제 제목을 제 안에 들고 있어 이 구획 자신이 공용 접기 컨테이너가 된다(B03~B07 과 같은 틀).
box.className = "b08-quantity__factors ui-collapsible";
const title = document.createElement("div");
title.className = "b08-quantity__field";
title.className = "b08-quantity__field ui-collapsible__title";
const titleName = document.createElement("span");
titleName.textContent = L("B08_Quantity_Side_Factors");
title.append(titleName);
@@ -188,6 +188,33 @@ const CSS = `
.b08-quantity__hint { margin: 0 0 6px; font-size: 11px; line-height: 1.4; color: var(--color-text-secondary); }
/* 품셈 범위 밖을 고른 칸 — **막지 않고** 사유를 받는 자리라 경고 색만 준다. */
.b08-quantity__hint--warn { color: var(--color-warning, #b45309); }
/* B03~B07 ( ui-collapsible
+ ui-sidebar-section + ui-sidebar-actions).
.ui-sidebar-section(ui_template_overlay.css)
border (B04 ). */
.b08-quantity__panel { display: flex; flex-direction: column; gap: 8px; }
.b08-quantity__section {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 10px 12px;
border-radius: var(--radius-cards);
background-color: var(--color-surface-raised);
}
.b08-quantity__section-title {
margin: 0 0 2px;
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary);
}
/* ui-sidebar-actions ** **
( )
(2026-09-12 ). . */
.b08-quantity__dev { display: flex; flex-direction: column; gap: 4px; }
.b08-quantity__dev-actions { display: flex; gap: 8px; }
.b08-quantity__dev-actions > * { flex: 1 1 0; min-width: 0; }
.b08-quantity__note { margin: 0; font-size: 11px; line-height: 1.4; color: var(--color-text-secondary); }
/* 토량환산계수 구획 — 갈래마다 (값 · 안내 · 사유)가 한 덩어리로 붙는다. */
.b08-quantity__factors { margin: 4px 0 10px; }
.b08-quantity__factor { margin-bottom: 6px; }
+14 -3
View File
@@ -11,6 +11,8 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, showToast } from "@ui/ui_template_elements";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { groupPanelSections } from "./B08_Quantity_UI_SidePanel_Sections";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
@@ -353,7 +355,10 @@ function devUnlockRow(projectId: string, reload: () => void): HTMLElement {
onClick: () => call("DELETE", relock),
});
const buttons = document.createElement("div");
buttons.className = "b08-quantity__actions ui-sidebar-actions";
// ⚠ 공용 `ui-sidebar-actions` 를 쓰지 않는다 — 공용 코드가 **첫 번째** 그 클래스를
// 패널 바닥 액션 줄로 집어(`ui_template_overlay.ts` splitSidebarActions),
// 스크롤 영역이 이 줄 안에 갇히고 아래 칸들이 통째로 잘린다(2026-09-12).
buttons.className = "b08-quantity__dev-actions";
buttons.append(unlock, relock);
row.append(buttons);
return row;
@@ -640,6 +645,8 @@ function buildQuantitySidePanel(
},
),
);
// ⚠ `?.` 이 빠지면 표를 못 받은 때(`table === null`) 여기서 터져 **페이지가 통째로
// 백지**가 된다 — 정작 보여야 할 「표를 못 불렀다」 안내까지 같이 사라진다(2026-09-12 실측).
const placing = (
table as unknown as {
concrete_placing?: {
@@ -647,8 +654,8 @@ function buildQuantitySidePanel(
is_default: boolean;
price_hint?: { basis?: string; values?: Record<string, number> };
};
}
).concrete_placing;
} | null
)?.concrete_placing;
if (placing) {
// ⚠ 방식 이름을 **늘** 값 옆에 보인다 — 코드(`12-01-01`)만으로는 무엇을 쓰는지 모른다.
const label = PLACING_LABELS[placing.method] ?? placing.method;
@@ -743,6 +750,10 @@ function buildQuantitySidePanel(
// TODO(미결) — 설정의 초기값을 무엇으로 볼지 사용자 확인 뒤에 붙인다.
actions.append(saveButton, confirmButton);
panel.append(actions);
// 조건 칸을 B03~B07 공통 상자로 묶고 제목 클릭으로 접히게 한다.
groupPanelSections(panel);
attachCollapsible(panel);
return panel;
}
@@ -0,0 +1,64 @@
/* =============================================================================
* B08_Quantity_UI_SidePanel_Sections.ts
* **B03~B07 ** .
*
* (`B08_Quantity_UI_Page.ts`) 700
* (CLAUDE.md 4 700 ).
* ========================================================================== */
/**
* + **B03~B07 ** .
*
*
* 400 append .
* `field(이름, "")` ** **, .
* · B04·B06 .
*
* ** .**
* .
* ( · ) ** **
* .
*/
export function groupPanelSections(panel: HTMLElement): void {
const isHeading = (node: Element): boolean => {
if (node.tagName !== "DIV" || !node.classList.contains("b08-quantity__field")) return false;
const value = node.querySelector(".b08-quantity__field-value");
return !value || !value.textContent;
};
const newSection = (title: string | null): HTMLElement => {
const section = document.createElement("section");
section.className = title
? "b08-quantity__section ui-collapsible ui-sidebar-section"
: "b08-quantity__section ui-sidebar-section";
if (title) {
const heading = document.createElement("p");
heading.className = "b08-quantity__section-title ui-collapsible__title";
heading.textContent = title;
section.append(heading);
}
return section;
};
let box: HTMLElement | null = null;
for (const node of [...panel.children]) {
// 개발용 줄·바닥 액션 줄은 건너뛰고 상자도 끊는다.
if (
node.classList.contains("b08-quantity__dev") ||
node.classList.contains("ui-sidebar-actions")
) {
box = null;
continue;
}
if (isHeading(node)) {
box = newSection(node.firstElementChild?.textContent ?? "");
panel.insertBefore(box, node);
node.remove();
continue;
}
if (!box) {
box = newSection(null);
panel.insertBefore(box, node);
}
box.append(node);
}
}
+25
View File
@@ -516,6 +516,31 @@ FOREST_ROAD_PROFILE_CRITERIA = {
# 배향곡선(Hair Pin) 중심선 반지름 하한(m, 별표2 Ⅰ.2.다.(2)). 이보다 급하면 **경고만**
# 낸다 — 자동 보정·차단은 하지 않는다(2026-09-06 사용자 확정).
"hairpin_min_radius_m": 10.0,
# 임도 종류별 **못 넘는 하한**(m) — 계획노선 편집 화면이 값을 막는 기준이다
# (2026-09-12 사용자 확정). 위 `min_plan_radius_m` 은 **기본값·위반 표시 기준**이고
# 여기는 **제한**이라 서로 다르다. 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어
# 곡선이 아예 안 그려진다.
# · None = 위 표(설계속도 × 지형)를 그대로 하한으로 쓴다.
# · 0.0 = 제한 없음. 작업임도는 별표2에 곡선반지름 규정이 없어 열어 둔다 —
# 값이 정해지면 **이 칸만** 고치면 서버·화면이 함께 따라간다.
# ⚠ `projects.road_type` 은 main|fire|work 로 들어온다(B02 스키마) — 계획선 등급 코드
# trunk 와 같은 뜻이라 둘 다 적어 둔다. 없는 키는 None 과 같게(법정 표) 다뤄진다.
"plan_radius_limit_by_grade_m": {
"main": None,
"trunk": None,
"fire": None,
"work": 0.0,
"branch": None,
},
# 평면 **곡선 길이(L)** 하한(m). 법령·교본에 값이 없어 지금은 전부 0(제한 없음)이다.
# 자리만 만들어 두고, 실무값이 정해지면 여기에 적는다(2026-09-12 사용자 확정).
"plan_curve_length_limit_by_grade_m": {
"main": 0.0,
"trunk": 0.0,
"fire": 0.0,
"work": 0.0,
"branch": 0.0,
},
# 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이
# 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서
# 설계자가 고르고, 작업임도는 20 이하이므로 20 고정이다. 사용자가 화면에서 고른
@@ -0,0 +1,37 @@
---
type: concept
status: stable
related_pages: ["[[multi_environment_safety]]", "[[design_data_lifecycle]]"]
last_updated: 2026-09-12
source: ["docs/raw/verification/2026-09-12_공용_브라우저_운용.md"]
---
# 화면 검증 브라우저 운용
## 기본 선택
- 화면 검증 기본은 Orca 내장 브라우저다. 공용 브라우저는 사용자가 명시한 때만 쓴다.
- 공용 창과 Orca 탭을 동시에 띄우면 `snapshot`·`screenshot`이 충돌하므로 함께 사용하지 않는다.
- 검증은 직접 조작하고 `sessionStorage`, SVG 좌표, 툴팁, 스크린샷 등 수치 근거로 판정한다. 바꾼 값은 원래 상태로 복원한다.
## 공용 브라우저 시동과 조작
| 항목 | 위치·명령 | 역할 |
|---|---|---|
| 시동 | `./venv/Scripts/python.exe .claude/dev_up.py` | 서버와 공용 창을 시작하거나 살아 있는 프로세스 재사용 |
| Orca용 시동 | `dev_up.py --no-browser` | 서버만 시작 |
| 명령 큐 | `tmp/browser/cmd/NN_이름.py` | `page`, `log`, `shot`, `time` 전역으로 이름순 조작 |
| 드라이버 로그 | `tmp/browser/driver.log` | RUN·OK·ERROR·SHOT 결과 기록 |
| 스크린샷 | `tmp/browser/shots/` | 화면 검증 증거 저장 |
`tmp/`는 환경 사이에 전달되지 않는다. 남길 시험은 `resources/tester/`에 둔다. 브라우저 창 종료·재시작은 사용자 지시가 있을 때만 하며, 캐시는 CDP `Network.clearBrowserCache``page.reload()`로 비운다.
## 변경 종류별 반영
| 변경 | 공용 브라우저 | Orca |
|---|---|---|
| 페이지·공용 TypeScript | `page.reload()` | `orca reload` |
| B07 WebCAD | `npm run build` 후 캐시 비우기 | 빌드 후 `orca reload`; 캐시 잔존 여부 미확인 |
| Python·도면 템플릿 JSON | 백엔드만 재시작 | 백엔드만 재시작 |
공용 서버·브라우저의 포트와 주인은 현재 `OWNERS.md`를 따른다. `sessionStorage`는 포트별로 갈리므로 검증 포트에서 상태를 다시 세운다.
+16 -5
View File
@@ -1,9 +1,9 @@
---
type: concept
status: stable
related_pages: ["[[architecture/shared_resources]]", "[[storage_paths]]", "[[workflow_state]]", "[[design_data_lifecycle]]"]
last_updated: 2026-09-11
source: ["docs/raw/verification/2026-09-09d_OWNERS_이력.md", "docs/raw/verification/2026-09-09e_계획서_완료근거_이관.md", "docs/raw/verification/2026-09-09f_계획서_공통기반과_참고_이관.md", "docs/raw/verification/2026-09-11_창환경_링크와_깃운영_조사.md", "docs/raw/plans/2026-09-11_plan_창환경_여섯워크트리_링크_깃운영.md"]
related_pages: ["[[architecture/shared_resources]]", "[[storage_paths]]", "[[workflow_state]]", "[[design_data_lifecycle]]", "[[browser_verification_operations]]"]
last_updated: 2026-09-12
source: ["docs/raw/verification/2026-09-09d_OWNERS_이력.md", "docs/raw/verification/2026-09-09e_계획서_완료근거_이관.md", "docs/raw/verification/2026-09-09f_계획서_공통기반과_참고_이관.md", "docs/raw/verification/2026-09-11_창환경_링크와_깃운영_조사.md", "docs/raw/plans/2026-09-11_plan_창환경_여섯워크트리_링크_깃운영.md", "docs/raw/verification/2026-09-12_깃_합류점_dev_전환.md"]
---
# 다중 환경 저장소·공용 DB 안전
@@ -31,15 +31,26 @@ source: ["docs/raw/verification/2026-09-09d_OWNERS_이력.md", "docs/raw/verific
| 구분 | 확정 운영 |
|---|---|
| 코드 환경 | `main_laptop_1`, `sub_laptop_1`, `main_desktop_1`, `sub_desktop_1` |
| AI 환경 | 데스크탑의 `CODEX`, `안티그래비티`; AI 브랜치는 `docs/`만 전달 |
| AI 환경 | 데스크탑의 `CODEX`, `안티그래비티`; 코드 작업은 사용자 지시가 있을 때만 수행 |
| 메인 동기화 | 두 메인 폴더의 `.claude`, `.codex`, 루트 지침·장부는 Synology가 전달 |
| 보조·AI 링크 | 폴더 `.claude`·`.codex`·`venv`는 정션, 루트 파일 넷은 심볼릭 링크 |
| 링크 원본 | 각 PC의 `OWNERS.md`에 적힌 자기 PC 메인 경로 |
| 신규 워크트리 | Orca가 생성·삭제, `worktree_setup.ps1`가 설치 후 `worktree_link.ps1` 실행 |
| Git 동기화 | 기존 스크립트가 fetch·merge·자동 push 담당; 브랜치 이름 패턴은 넓히지 않음 |
| Git 동기화 | `main`은 정본, `dev`는 합류점, 환경 브랜치 6개는 작업 자리 |
링크는 Synology 동기화 루트 밖에만 만들고, 지침·장부를 `.claude``.codex` 안으로 옮기지 않는다. `worktree_link.ps1`는 살아 있는 하드링크만 심볼릭 링크로 갈아타며 갈라진 실물은 지우지 않는다.
## Git 한 바퀴
| 단계 | 규칙 |
|---|---|
| 받기 | 작업 시작 전 자기 워크트리에서 `.claude\git-sync.ps1`를 인자 없이 한 번 실행 |
| 작업 | 자기 환경 브랜치에서 지시받은 범위만 변경 |
| 밀기 | 경로를 지정해 커밋한 뒤 `git push origin HEAD`로 자기 브랜치에만 push |
| 모으기 | 사용자가 Git 싱크를 지시한 때만 `git-sync.ps1 -Converge` 실행 |
`git-sync.ps1``origin/dev`를 받고, `dev`가 아직 품지 않은 환경 브랜치를 한 번의 옥토퍼스 머지로 담은 뒤 `dev`와 현재 환경 브랜치를 빨리감기한다. `-Brief`는 작업 중 HEAD를 바꾸지 않고 미수신 커밋만 알린다. 미커밋이 있으면 받기를 통째로 건너뛰며, `main``dev`에 손으로 push하지 않는다.
## 완료 판정
- 랩탑 AI 워크트리 둘 제거, 데스크탑 AI 워크트리 둘 생성, 랩탑·데스크탑 보조 및 AI 워크트리 링크 구성을 완료했다.
@@ -171,14 +171,21 @@
"169": "B08_DesignDetail_Engine_Cad_Basin.py",
"170": "B08_DesignDetail_Engine_Cad_MassHaul.py",
"171": "B08 CAD·납품 도면 후속",
"172": "2026-09-04 완료 항목",
"173": "multi_environment_safety.md",
"174": "Workflow 상태 관리",
"175": "DB 스키마 개요",
"176": "OpenWebCAD Core",
"177": "common_util_mass_haul_settle.ts",
"178": "Drainage Watershed (유역도)",
"179": "Mass Haul Diagram (토적도)",
"180": "다중 환경 저장소·공용 DB 안전",
"181": "저장 경로 규칙 (Workflow-based Folder Structure)",
"182": "2026-09-02 완료 항목",
"183": "2026-09-03 완료 — 입력·배수·종횡단·CAD",
"184": "2026-09-03 추가 완료 — 화면·종단·횡단",
"185": "Query: 임도 집수정 형태정보",
"186": "설계 데이터 생명주기",
"187": "B03 File Input Backend",
"188": "B03 File Input Frontend",
"189": "B04 PreProcess Backend",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
{
"0": "UI Templates — Localization & Components",
"1": "인증 / RBAC",
"2": "A00_Common — App Shell Framework",
"3": "2026-09-04 완료 항목",
"4": "배수유역 해석 및 세부설계 (Drainage Watershed)",
"5": "DB: 로그/모니터링 테이블",
"6": "A09_Security — Backend",
"7": "2026-08-29 완료 반영",
"8": "DB: 파일/지표면분석 테이블",
"9": "DB: 경로/종횡단 테이블",
"10": "A04_NewsHistory — Frontend",
"11": "A07_Register — Frontend",
"12": "A08_Support — Frontend",
"13": "B02_ProjRegister — Frontend",
"14": "A01_Home — Frontend",
"15": "A05_EduDetail — Frontend",
"16": "A06_Login — Frontend",
"17": "A08_Support — Backend",
"18": "A09_Security — Frontend",
"19": "B02_ProjRegister — Backend",
"20": "B04_PreProcess — Backend",
"21": "B04_PreProcess — Frontend",
"22": "B05 구조물 정본·통합 편집",
"23": "B06_Section — Frontend",
"24": "A03_CompDetail — Frontend",
"25": "A06_Login — Backend",
"26": "유토곡선 (Mass Haul Diagram) 계산 명세",
"27": "A07_Register — Backend",
"28": "B06 배수관 구조물 조작·표시",
"29": "B06 기슭막이 연동·경사·단별 제어",
"30": "B08_DesignDetail — Backend",
"31": "B08 토적도·수리집수면적유역도",
"32": "B09_Estimation — Frontend",
"33": "현재 구현 현황 — 소스 읽기 감사",
"34": "DB: 구조물/수량/산출물 테이블",
"35": "B01_Dashboard — Backend",
"36": "B01_Dashboard — Frontend",
"37": "B03_FileInput — Backend",
"38": "B05 급선회 3D 국부 보정 계획",
"39": "B06 집수정·다단 기슭막이",
"40": "B06 배수관 구조물 기하·조작 체계",
"41": "B06 배수관 횡단도 세트",
"42": "B10_Payment — Frontend",
"43": "B11_Status — Frontend",
"44": "저장 경로 규칙 (Workflow-based Folder Structure)",
"45": "DB: 프로젝트 관리 테이블",
"46": "임도기술교본 원문 md 추출 품질 결함",
"47": "A01_Home — 세부 구현",
"48": "A02_ProgDetail — 세부 구현",
"49": "A02_ProgDetail — Frontend",
"50": "B01_Dashboard — API",
"51": "B03_FileInput — Frontend",
"52": "B05 계획노선 코리도 삼각망 서피스",
"53": "B05 구조물·UI 현재 계획",
"54": "B05 구조물 입력·종단 표시 정비 — 2026-08-19",
"55": "B07 구조물 표준도 — 조사·합의와 현재 통로",
"56": "B08 횡단도 구조물·장 배치",
"57": "2026-09-07 완료·보류 요약",
"58": "B07 외부 WebCAD 비교 실행환경",
"59": "Temp Upload (프로젝트 생성 전 임시 보관함)",
"60": "Q: 배수관 매설시 각도의 제약조건이 있는지 확인해줘. 임도에서",
"61": "Q: 임도 기술정보DB에서 집수정의 형태정보는 어떤게 있는지 확인해줘.",
"62": "B02_ProjRegister — DB",
"63": "B03_FileInput — API",
"64": "B04_PreProcess — DB",
"65": "B05_Profile — API",
"66": "B05 변형 성토면 마감·날개 패치",
"67": "B05_Profile — Profile Alignment & Table",
"68": "B05 구조물 비정규 측점 공급",
"69": "B05_Profile_UI_Drainage_Parts — 배수유역 공용 UI 파츠",
"70": "_UI_Drainage_Render — 배수유역도 Canvas 렌더러",
"71": "_UI_Profile_Structures — 종단 구조물 렌더링 및 인터랙션",
"72": "_UI_Selection — 배수/구조물 3자 선택 동기화",
"73": "B06 인접 측점 구조물 트림 정리",
"74": "B06 물넘이포장·콘크리트 포장·독립 기슭막이",
"75": "B06_Section_Engine_Areas — 횡단 면적 적분 연산 엔진",
"76": "B06_Section_Router_Confirm — 임시 저장 및 확정 라우터",
"77": "B06_Section_UI_Cross_Areas — 횡단 단면적 표기 및 밴드 하이라이트",
"78": "B06_Section_UI_Cross_Design — 횡단 측점별 세부 설계 컨트롤",
"79": "B06_Section_UI_MassHaul — 유토곡선 적분 계산 엔진",
"80": "B06_Section_UI_MassHaul_Balance — 평형선 및 장비 띠 분할 엔진",
"81": "B06_Section_UI_MassHaul_Balance_View — 운반 띠 시각화 렌더러",
"82": "B06_Section_UI_MassHaul_Balloon — 물량 말풍선 배치 및 조작",
"83": "B06_Section_UI_MassHaul_Curve — 유토곡선 궤적 보간 및 렌더링",
"84": "B06_Section_UI_MassHaul_Settle — 토량 정산 및 장거리 상쇄 엔진",
"85": "B06_Section_UI_MassHaul_View — 유토곡선 시각화 렌더러",
"86": "B06_Section_UI_Page — B06 메인 페이지 오케스트레이터",
"87": "B06_Section_UI_Section_View — 종/횡단 및 유토곡선 뷰 조립",
"88": "B07 Quantity — Backend",
"89": "B08 CAD 블록 라이브러리·사진",
"90": "B08 OpenWebCAD 명령 체계",
"91": "B08_CAD_table_entity.md",
"92": "공개·인증·관리 영역 지도",
"93": "공유 자원 영향 지도",
"94": "A00_Common — 공통 프레임워크 & 유틸",
"95": "app_shell.ts",
"96": "router.ts",
"97": "B04_PreProcess — API",
"98": "B04_PreProcess — Dependencies",
"99": "B05_Profile — Backend",
"100": "B05_Profile — DB 사용 관계",
"101": "B05_Profile — 3D Viewer & Interaction",
"102": "B05_Profile_Engine_Sections.md",
"103": "B05_Profile_Api_Fetch.ts",
"104": "B05_Profile_UI_Drainage_Panel",
"105": "B05_Profile_UI_Drainage_Pipes",
"106": "B05_Profile_UI_Profile_Panel.md",
"107": "B06_Section — DB 사용 관계",
"108": "B06_Section_Api_Fetch — B06 프론트엔드 API 클라이언트",
"109": "B06_Section_UI_Standard_Diagram — 표준단면 모식도 컴포넌트",
"110": "B06_Section_UI_Standard_Panel — 표준단면 입력 및 제어 패널",
"111": "B08_DesignDetail — Dependencies",
"112": "common_util_project_delete.md",
"113": "common_util_storage.md",
"114": "common_util_workflow_state.md",
"115": "미확정 테이블 보관소 (Unconfirmed DB Schemas)",
"116": "A01_Home_UI_Page.md",
"117": "A02_ProgDetail_UI_Page.md",
"118": "A06_Login_Router.md",
"119": "A07_Register_Router.md",
"120": "A08_Support_Router.md",
"121": "A09_Security_Router.md",
"122": "B01_Dashboard — DB 사용 관계",
"123": "B01_Dashboard — Dependencies",
"124": "B01_Dashboard_Router.md",
"125": "B01_Dashboard_UI_Page.md",
"126": "B02_ProjRegister_Router.md",
"127": "B03_FileInput — DB 사용 관계",
"128": "B03_FileInput — Dependencies",
"129": "B03_FileInput_Router.md",
"130": "B04_PreProcess_Router.md",
"131": "B05_Profile — Dependencies",
"132": "B05_Profile_Engine_Grade.md",
"133": "B05_Profile_Engine_Solver.md",
"134": "B05_Profile_Repository.md",
"135": "B05_Profile_Router.md",
"136": "B05_Profile_Router_Confirm.md",
"137": "B05_Profile_Schema.md",
"138": "B05_Profile_UI_IrregularStations.md",
"139": "B05_Profile_UI_Page.md",
"140": "B05_Profile_UI_Panel.md",
"141": "B05_Profile_UI_Profile_Alignment.md",
"142": "B05_Profile_UI_Profile_Table.md",
"143": "B05_Profile_UI_Viewer.md",
"144": "B06_Section — Dependencies",
"145": "B06_Section_Router.md",
"146": "B01~B09 Workflow 데이터 흐름",
"147": "B08_DesignDetail — API",
"148": "longitudinal_alignment.md",
"149": "B03_FileInput_plan_lidar_multi_file.md",
"150": "B05_completed_followups.md",
"151": "B05_corridor_followup_decisions.md",
"152": "B06_api.md",
"153": "B07_db.md",
"154": "B08_DesignDetail_Router.md",
"155": "B09_Estimation_UI_Page.md",
"156": "B10_Payment_UI_Page.md",
"157": "B11_Status_UI_Page.md",
"158": "B03 계획노선 정본·좌표계 후속",
"159": "B03 파일 입력 화면 정리",
"160": "B04 3D 방위·좌표계 최신 결정",
"161": "B04 세부유역·방위·좌표계",
"162": "B05 구조물 구간 절취·측벽·성토 패치",
"163": "B05 구조물 3D 투영 커브",
"164": "B05_Profile — Frontend",
"165": "B05 유토곡선·구조물 후속",
"166": "B05 종단곡선·실시간 횡단 연동",
"167": "B06 횡단 계산 미러·카드 표기",
"168": "B06 횡단 관 형상·유토곡선 후속",
"169": "B08_DesignDetail_Engine_Cad_Basin.py",
"170": "B08_DesignDetail_Engine_Cad_MassHaul.py",
"171": "B08 CAD·납품 도면 후속",
"176": "OpenWebCAD Core",
"177": "common_util_mass_haul_settle.ts",
"178": "Drainage Watershed (유역도)",
"179": "Mass Haul Diagram (토적도)",
"182": "2026-09-02 완료 항목",
"183": "2026-09-03 완료 — 입력·배수·종횡단·CAD",
"184": "2026-09-03 추가 완료 — 화면·종단·횡단",
"185": "Query: 임도 집수정 형태정보",
"187": "B03 File Input Backend",
"188": "B03 File Input Frontend",
"189": "B04 PreProcess Backend",
"190": "B04 PreProcess Frontend",
"191": "B07_Quantity_Router.md",
"192": "B08 CAD Blocks Library",
"193": "B08 CAD Table Entity",
"194": "B08 Cross Section Structure Sheets",
"195": "Query: 배수관 매설 각도 제약조건 (임도)"
}
@@ -0,0 +1 @@
{"output_tokens": 2385}
@@ -0,0 +1,891 @@
# Graph Report - wiki (2026-09-11)
## Corpus Check
- 206 files · ~57,686 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1170 nodes · 996 edges · 189 communities (155 shown, 34 thin omitted)
- Extraction: 100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `e49e4d85`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- UI Templates — Localization & Components
- 인증 / RBAC
- A00_Common — App Shell Framework
- 2026-09-04 완료 항목
- 배수유역 해석 및 세부설계 (Drainage Watershed)
- DB: 로그/모니터링 테이블
- A09_Security — Backend
- 2026-08-29 완료 반영
- DB: 파일/지표면분석 테이블
- DB: 경로/종횡단 테이블
- A04_NewsHistory — Frontend
- A07_Register — Frontend
- A08_Support — Frontend
- B02_ProjRegister — Frontend
- A01_Home — Frontend
- A05_EduDetail — Frontend
- A06_Login — Frontend
- A08_Support — Backend
- A09_Security — Frontend
- B02_ProjRegister — Backend
- B04_PreProcess — Backend
- B04_PreProcess — Frontend
- B05 구조물 정본·통합 편집
- B06_Section — Frontend
- A03_CompDetail — Frontend
- A06_Login — Backend
- 유토곡선 (Mass Haul Diagram) 계산 명세
- A07_Register — Backend
- B06 배수관 구조물 조작·표시
- B06 기슭막이 연동·경사·단별 제어
- B08_DesignDetail — Backend
- B08 토적도·수리집수면적유역도
- B09_Estimation — Frontend
- 현재 구현 현황 — 소스 읽기 감사
- DB: 구조물/수량/산출물 테이블
- B01_Dashboard — Backend
- B01_Dashboard — Frontend
- B03_FileInput — Backend
- B05 급선회 3D 국부 보정 계획
- B06 집수정·다단 기슭막이
- B06 배수관 구조물 기하·조작 체계
- B06 배수관 횡단도 세트
- B10_Payment — Frontend
- B11_Status — Frontend
- 저장 경로 규칙 (Workflow-based Folder Structure)
- DB: 프로젝트 관리 테이블
- 임도기술교본 원문 md 추출 품질 결함
- A01_Home — 세부 구현
- A02_ProgDetail — 세부 구현
- A02_ProgDetail — Frontend
- B01_Dashboard — API
- B03_FileInput — Frontend
- B05 계획노선 코리도 삼각망 서피스
- B05 구조물·UI 현재 계획
- B05 구조물 입력·종단 표시 정비 — 2026-08-19
- B07 구조물 표준도 — 조사·합의와 현재 통로
- B08 횡단도 구조물·장 배치
- 2026-09-07 완료·보류 요약
- B07 외부 WebCAD 비교 실행환경
- Temp Upload (프로젝트 생성 전 임시 보관함)
- Q: 배수관 매설시 각도의 제약조건이 있는지 확인해줘. 임도에서
- Q: 임도 기술정보DB에서 집수정의 형태정보는 어떤게 있는지 확인해줘.
- B02_ProjRegister — DB
- B03_FileInput — API
- B04_PreProcess — DB
- B05_Profile — API
- B05 변형 성토면 마감·날개 패치
- B05_Profile — Profile Alignment & Table
- B05 구조물 비정규 측점 공급
- B05_Profile_UI_Drainage_Parts — 배수유역 공용 UI 파츠
- _UI_Drainage_Render — 배수유역도 Canvas 렌더러
- _UI_Profile_Structures — 종단 구조물 렌더링 및 인터랙션
- _UI_Selection — 배수/구조물 3자 선택 동기화
- B06 인접 측점 구조물 트림 정리
- B06 물넘이포장·콘크리트 포장·독립 기슭막이
- B06_Section_Engine_Areas — 횡단 면적 적분 연산 엔진
- B06_Section_Router_Confirm — 임시 저장 및 확정 라우터
- B06_Section_UI_Cross_Areas — 횡단 단면적 표기 및 밴드 하이라이트
- B06_Section_UI_Cross_Design — 횡단 측점별 세부 설계 컨트롤
- B06_Section_UI_MassHaul — 유토곡선 적분 계산 엔진
- B06_Section_UI_MassHaul_Balance — 평형선 및 장비 띠 분할 엔진
- B06_Section_UI_MassHaul_Balance_View — 운반 띠 시각화 렌더러
- B06_Section_UI_MassHaul_Balloon — 물량 말풍선 배치 및 조작
- B06_Section_UI_MassHaul_Curve — 유토곡선 궤적 보간 및 렌더링
- B06_Section_UI_MassHaul_Settle — 토량 정산 및 장거리 상쇄 엔진
- B06_Section_UI_MassHaul_View — 유토곡선 시각화 렌더러
- B06_Section_UI_Page — B06 메인 페이지 오케스트레이터
- B06_Section_UI_Section_View — 종/횡단 및 유토곡선 뷰 조립
- B07 Quantity — Backend
- B08 CAD 블록 라이브러리·사진
- B08 OpenWebCAD 명령 체계
- B08_CAD_table_entity.md
- 공개·인증·관리 영역 지도
- 공유 자원 영향 지도
- A00_Common — 공통 프레임워크 & 유틸
- app_shell.ts
- router.ts
- B04_PreProcess — API
- B04_PreProcess — Dependencies
- B05_Profile — Backend
- B05_Profile — DB 사용 관계
- B05_Profile — 3D Viewer & Interaction
- B05_Profile_Engine_Sections.md
- B05_Profile_Api_Fetch.ts
- B05_Profile_UI_Drainage_Panel
- B05_Profile_UI_Drainage_Pipes
- B05_Profile_UI_Profile_Panel.md
- B06_Section — DB 사용 관계
- B06_Section_Api_Fetch — B06 프론트엔드 API 클라이언트
- B06_Section_UI_Standard_Diagram — 표준단면 모식도 컴포넌트
- B06_Section_UI_Standard_Panel — 표준단면 입력 및 제어 패널
- B08_DesignDetail — Dependencies
- common_util_project_delete.md
- common_util_storage.md
- common_util_workflow_state.md
- 미확정 테이블 보관소 (Unconfirmed DB Schemas)
- A01_Home_UI_Page.md
- A02_ProgDetail_UI_Page.md
- A06_Login_Router.md
- A07_Register_Router.md
- A08_Support_Router.md
- A09_Security_Router.md
- B01_Dashboard — DB 사용 관계
- B01_Dashboard — Dependencies
- B01_Dashboard_Router.md
- B01_Dashboard_UI_Page.md
- B02_ProjRegister_Router.md
- B03_FileInput — DB 사용 관계
- B03_FileInput — Dependencies
- B03_FileInput_Router.md
- B04_PreProcess_Router.md
- B05_Profile — Dependencies
- B05_Profile_Engine_Grade.md
- B05_Profile_Engine_Solver.md
- B05_Profile_Repository.md
- B05_Profile_Router.md
- B05_Profile_Router_Confirm.md
- B05_Profile_Schema.md
- B05_Profile_UI_IrregularStations.md
- B05_Profile_UI_Page.md
- B05_Profile_UI_Panel.md
- B05_Profile_UI_Profile_Alignment.md
- B05_Profile_UI_Profile_Table.md
- B05_Profile_UI_Viewer.md
- B06_Section — Dependencies
- B06_Section_Router.md
- B01~B09 Workflow 데이터 흐름
- B08_DesignDetail — API
- longitudinal_alignment.md
- B03_FileInput_plan_lidar_multi_file.md
- B05_completed_followups.md
- B05_corridor_followup_decisions.md
- B06_api.md
- B07_db.md
- B08_DesignDetail_Router.md
- B09_Estimation_UI_Page.md
- B10_Payment_UI_Page.md
- B11_Status_UI_Page.md
- B03 계획노선 정본·좌표계 후속
- B03 파일 입력 화면 정리
- B04 3D 방위·좌표계 최신 결정
- B04 세부유역·방위·좌표계
- B05 구조물 구간 절취·측벽·성토 패치
- B05 구조물 3D 투영 커브
- B05_Profile — Frontend
- B05 유토곡선·구조물 후속
- B05 종단곡선·실시간 횡단 연동
- B06 횡단 계산 미러·카드 표기
- B06 횡단 관 형상·유토곡선 후속
- B08_DesignDetail_Engine_Cad_Basin.py
- B08_DesignDetail_Engine_Cad_MassHaul.py
- B08 CAD·납품 도면 후속
- OpenWebCAD Core
- common_util_mass_haul_settle.ts
- Drainage Watershed (유역도)
- Mass Haul Diagram (토적도)
- 2026-09-02 완료 항목
- 2026-09-03 완료 — 입력·배수·종횡단·CAD
- 2026-09-03 추가 완료 — 화면·종단·횡단
- Query: 임도 집수정 형태정보
- B03 File Input Backend
- B03 File Input Frontend
- B04 PreProcess Backend
- B04 PreProcess Frontend
- B07_Quantity_Router.md
- B08 CAD Blocks Library
- B08 CAD Table Entity
- B08 Cross Section Structure Sheets
- Query: 배수관 매설 각도 제약조건 (임도)
## God Nodes (most connected - your core abstractions)
1. `UI Templates — Localization & Components` - 12 edges
2. `DB: 로그/모니터링 테이블` - 11 edges
3. `B07 구조물 표준도 — 조사·합의와 현재 통로` - 11 edges
4. `2026-08-29 완료 반영` - 10 edges
5. `DB: 파일/지표면분석 테이블` - 10 edges
6. `A07_Register — Frontend` - 10 edges
7. `A08_Support — Frontend` - 10 edges
8. `B02_ProjRegister — Frontend` - 10 edges
9. `인증 / RBAC` - 9 edges
10. `배수유역 해석 및 세부설계 (Drainage Watershed)` - 9 edges
## Surprising Connections (you probably didn't know these)
- `B07 구조물 표준도 — 조사·합의와 현재 통로` --references--> `B07 DesignDetail — 현재 책임` [EXTRACTED]
pages/B07_DesignDetail/B07_standard_drawings_2026_09.md → pages/B07_DesignDetail/B07_frontend.md
- `B07 구조물 표준도 — 조사·합의와 현재 통로` --references--> `B08 Quantity — 2026-09 수량산출` [EXTRACTED]
pages/B07_DesignDetail/B07_standard_drawings_2026_09.md → pages/B08_Quantity/B08_overview_2026_09.md
- `B07 구조물 표준도 — 조사·합의와 현재 통로` --references--> `B09 Estimation — 2026-09 원가계산` [EXTRACTED]
pages/B07_DesignDetail/B07_standard_drawings_2026_09.md → pages/B09_Estimation/B09_overview_2026_09.md
## Import Cycles
- None detected.
## Hyperedges (group relationships)
- **B03-B08 Workflow Data Flow** — b03_fileinput_route_snapshot_crs, b04_preprocess_drainage_compass_crs, b05_profile_frontend, b06_section_cross_design_ui_2026_09, b08_designdetail_frontend [INFERRED 0.90]
- **Shared Mass Haul Calculation and UI** — b05_profile_masshaul_structure_2026_09, b06_section_masshaul_culvert_2026_09, b08_designdetail_cad_delivery_2026_09 [EXTRACTED 0.85]
- **CAD Delivery and Usability Framework** — b08_designdetail_cad_interaction, b08_designdetail_cad_title_block, b08_designdetail_cad_usability_2026_09_01, b08_designdetail_cad_delivery_2026_09 [EXTRACTED 0.95]
- **LAS-Free Analysis Workflow** — concepts_las_free_sheet_surface, pages_b03_fileinput_backend, pages_b04_preprocess_backend [EXTRACTED 1.00]
- **B08 Drawing Generation Flow** — b08_designdetail_b08_designdetail_engine_cad_masshaul_py, b08_designdetail_b08_designdetail_engine_cad_basin_py, common_util_common_util_mass_haul_settle_ts [EXTRACTED 0.90]
- **Drainage System Workflow** — concepts_drainage_watershed, pages_b05_profile_b05_structures, pages_b06_section_b06_culvert_set, pages_b06_section_b06_culvert_geometry_redesign [EXTRACTED 0.95]
- **Mass Haul Diagram System** — concepts_mass_haul_diagram, pages_b06_section_b06_frontend, pages_b08_designdetail_b08_drawing_masshaul_watershed [EXTRACTED 0.90]
- **3D Corridor Generation Flow** — pages_b05_profile_b05_corridor_surface, pages_b05_profile_b05_corridor_plan_curves, pages_b05_profile_b05_corridor_cut_fill, pages_b05_profile_b05_corridor_patch_finish [EXTRACTED 0.90]
- **Late Workflow Stages (B07-B09)** — pages_b07_quantity_b07_frontend, pages_b08_designdetail_b08_frontend, pages_b09_estimation_b09_frontend [EXTRACTED 1.00]
## Communities (189 total, 34 thin omitted)
### Community 0 - "UI Templates — Localization & Components"
Cohesion: 0.05
Nodes (38): 2026-09-04 완료 항목, 700줄 제한 분리, 보존된 미완료 범위, 상시계획서 추가 완료 범위, 완료 범위, 추가 완료 범위, 디자인 시스템 (Design System), 레이아웃 및 둥근 테두리 (Radius & Spacing) (+30 more)
### Community 1 - "인증 / RBAC"
Cohesion: 0.06
Nodes (31): OTP / 비밀번호 및 디바이스 신뢰, 권한 검증 헬퍼 (B01_Dashboard), 라우팅 가드 (frontend.md 5.2), 사용자 상태 생명주기, 사용처 (역참조), 세션 인증 (backend.md 6.3), 역할 (users.role), 인증 / RBAC (+23 more)
### Community 2 - "A00_Common — App Shell Framework"
Cohesion: 0.08
Nodes (22): A00_Common — App Shell Framework, app_shell 구성요소, router 라우팅 테이블, A00_Common — 스캐폴드·CSS·종속성, b_page_scaffold, CSS 인젝션, 사용처, 종속성 (+14 more)
### Community 3 - "2026-09-04 완료 항목"
Cohesion: 0.07
Nodes (25): API 공통 (여러 페이지가 공유하는 엔드포인트), 공통 오류 응답 포맷 (전 라우터), 워크플로우 상태 조회, 폴링 패턴 (legacy workflow.json 설계; 현재 구현은 workflow-state API 사용), 계산 구현 원칙, 계획노선 규칙, 설계 데이터 생명주기, 정본 세 벌 (+17 more)
### Community 4 - "배수유역 해석 및 세부설계 (Drainage Watershed)"
Cohesion: 0.25
Nodes (7): B08_DesignDetail — Frontend, CAD 계획선 레이어 연동 및 편집 지원 (2026-07-22), openwebcad 단위 정합, 선 특성/폰트 UI 및 Fit-in-all (2026-07-20), 독립형 CAD 임베드 및 데이터 연동 아키텍처, 의존성, 주요 컴포넌트 / 함수, 파일 구조
### Community 5 - "DB: 로그/모니터링 테이블"
Cohesion: 0.17
Nodes (11): activity_logs (사용자 활동 로그), audit_logs (감사 로그), change_logs (설계 변경 이력), DB: 로그/모니터링 테이블, login_logs (로그인 시도 로그), support_requests (기술 지원 요청), system_admin_logs (시스템 관리자 행위 로그), system_audit_logs — 사용처 (B01, B02) (+3 more)
### Community 6 - "A09_Security — Backend"
Cohesion: 0.17
Nodes (11): A09_Security — Backend, API 엔드포인트, DB 저장 (activity_logs), 권한 헬퍼, 마스터(회사 관리자) 전용 — `require_master`, 시스템 관리자 전용 — `require_system_admin`, 요청 스키마 (Pydantic), 의존성 (공통 유틸) (+3 more)
### Community 7 - "2026-08-29 완료 반영"
Cohesion: 0.10
Nodes (17): 2026-08-29 완료 반영, B03 재업로드·B05 최신 조회, B05/B06 구조물 UI 통합, B07↔B08 순서, B07 CAD 고정 척도·횡단 장 배치, B07 CAD 테마, B07 CAD 확대·팬, 배수시설 추천 기준 (+9 more)
### Community 8 - "DB: 파일/지표면분석 테이블"
Cohesion: 0.18
Nodes (10): DB: 파일/지표면분석 테이블, input_files.status 값, input_files (입력 원본 파일), processed_point_cloud.status 값, processed_point_cloud (필터/변환 포인트클라우드), surface_models.status 값, surface_models (지표면 모델 및 등고선), terrain_layers (지형 레이어) (+2 more)
### Community 9 - "DB: 경로/종횡단 테이블"
Cohesion: 0.18
Nodes (10): cross_sections.data.structures, cross_sections (횡단면 설계), `data` 컬럼 내 `options` 스냅샷 구조 (2026-07-19 도입), `data` 컬럼 내 `profile_alignment` 구조 (2026-07-23 도입), DB: 경로/종횡단 테이블, longitudinal_sections (종단면 설계), route_points (경로 좌표점), route_statistics (노선 통계) (+2 more)
### Community 10 - "A04_NewsHistory — Frontend"
Cohesion: 0.18
Nodes (10): A04_NewsHistory — Frontend, CSS 클래스 구조, Mock 데이터 구조, 로컬라이제이션, 미해결 사항, 반응형, 스타일 (CSS), 의존성 (+2 more)
### Community 11 - "A07_Register — Frontend"
Cohesion: 0.18
Nodes (10): A07_Register — Frontend, API 클라이언트 함수, 로컬라이제이션, 스타일 (CSS), 약관 동의 (아코디언), 의존성, 이벤트 핸들러, 제출 로직 (2단계 폼 전환) (+2 more)
### Community 12 - "A08_Support — Frontend"
Cohesion: 0.18
Nodes (10): A08_Support — Frontend, 로컬라이제이션, 세션 자동 채움, 스타일 (CSS), 의존성, 이벤트 핸들러, 제출 로직, 참고 (+2 more)
### Community 13 - "B02_ProjRegister — Frontend"
Cohesion: 0.18
Nodes (10): B02_ProjRegister — Frontend, 로컬라이제이션, 스타일 (CSS), 의존성, 이벤트 핸들러, 입력 필드, 제출 로직, 참고 (+2 more)
### Community 14 - "A01_Home — Frontend"
Cohesion: 0.20
Nodes (9): A01_Home — Frontend, Hero 섹션, 로컬라이제이션, 세부 구현, 이벤트 핸들러, 주요 기능 섹션, 최신 소식 섹션, 컴포넌트 (+1 more)
### Community 15 - "A05_EduDetail — Frontend"
Cohesion: 0.20
Nodes (9): A05_EduDetail — Frontend, CSS 클래스 구조, 로컬라이제이션, 반응형, 스타일 (CSS), 의존성, 이벤트 핸들러, 컴포넌트 (섹션 빌더) (+1 more)
### Community 16 - "A06_Login — Frontend"
Cohesion: 0.20
Nodes (9): A06_Login — Frontend, API 클라이언트 함수, 로컬라이제이션, 스타일 (CSS), 의존성, 이벤트 핸들러, 제출 및 OTP 제어 로직 (2단계 폼 전환), 컴포넌트 / 함수 (+1 more)
### Community 17 - "A08_Support — Backend"
Cohesion: 0.20
Nodes (9): A08_Support — Backend, API 엔드포인트, DB 저장 컬럼 (실 코드 INSERT 기준), 내부 헬퍼, 요청 스키마 (Pydantic), 의존성 (공통 유틸), 접수 로직, 특징 (+1 more)
### Community 18 - "A09_Security — Frontend"
Cohesion: 0.20
Nodes (9): A09_Security — Frontend, API 클라이언트 함수 (⚠️ 미사용, 정의만), 로컬라이제이션, 스타일 (CSS), 약관 데이터 (A09_Security_Terms.ts), ⚠️ 약관 텍스트와 실 코드 불일치, 의존성, 컴포넌트 / 함수 (+1 more)
### Community 19 - "B02_ProjRegister — Backend"
Cohesion: 0.20
Nodes (9): API 엔드포인트, B02_ProjRegister — Backend, DB 저장 컬럼 (projects INSERT), 생성 로직 (create_project 트랜잭션), 요청/응답 스키마 (Pydantic), 의존성 (공통 유틸), 참고, 파일 구조 (+1 more)
### Community 20 - "B04_PreProcess — Backend"
Cohesion: 0.20
Nodes (9): 2D/GIS 미표시 원인 분석 및 조치, B04_PreProcess — Backend, 📋 구현 예외 처리 검토 항목 (PLAN), ⚙️ 설정 및 환경 파일 정합성, 수치지형도 도엽 오버레이 아키텍처 (2026-07-26, 2026-08-01 S8 개편), 엔진 서브모듈, 워크플로우 상태 전이 및 자동 확정, 주요 함수 (Router / Repository / Engine / Utility) (+1 more)
### Community 21 - "B04_PreProcess — Frontend"
Cohesion: 0.20
Nodes (9): 3D 뷰어 DOM 마운트 버그 수리, 3D 뷰어 테마 연동 및 가독성 개선, 3D 카메라 커서 피봇 & 2D 오버레이 UI 개선 (2026-08-01~02 일원화), B04_PreProcess — Frontend, ⚠️ 계획서와 실 코드 불일치 (3D 뷰어), 의존성, 처리 흐름, 컴포넌트 & API 함수 (+1 more)
### Community 22 - "B05 구조물 정본·통합 편집"
Cohesion: 0.20
Nodes (9): API, B05 구조물 정본·통합 편집, B06 경계와 남은 범위, 검증, 배수관·시설 옵션, 백엔드 파일, 유역 추천·개략 단면, 정본과 타입 레지스트리 (+1 more)
### Community 23 - "B06_Section — Frontend"
Cohesion: 0.20
Nodes (9): 2026-08-22 파일 한계 정리, API 클라이언트, B06_Section — Frontend, SVG 렌더러 및 유틸리티, UI 패널 크기 및 리사이저 규칙 (2026-08-02 신설), 기술부채 (해결됨), 입력 옵션 (표시 옵션 및 횡단 반폭 제어), 파일 구성 (+1 more)
### Community 24 - "A03_CompDetail — Frontend"
Cohesion: 0.22
Nodes (8): A03_CompDetail — Frontend, CSS 클래스 구조, 로컬라이제이션, 반응형, 스타일 (CSS), 의존성, 컴포넌트 (섹션 빌더), 파일 구조
### Community 25 - "A06_Login — Backend"
Cohesion: 0.22
Nodes (8): A06_Login — Backend, API 엔드포인트, 내부 헬퍼, 로그인 로직 흐름, 보안 정책 (실 코드 기준), 요청 스키마 (Pydantic), 의존성 (공통 유틸), 파일 구조
### Community 26 - "유토곡선 (Mass Haul Diagram) 계산 명세"
Cohesion: 0.15
Nodes (11): 1. 개요 및 분석 목적, 2. 주요 계산 수식 및 원리 (실무 관례 반영), 3. 지반유형별 토량환산계수 기본값 (`config_system.py`), 4. 토공 운반장비 선정거리 및 분배 기준 (`config_system.py`), 5. 유토곡선 곡선 사양 (B06 구현 v2), 6. 웹앱 연동 및 시각화 명세 (2026-08-02 확정), 유토곡선 (Mass Haul Diagram) 계산 명세, B08 Quantity — 2026-09 수량산출 (+3 more)
### Community 27 - "A07_Register — Backend"
Cohesion: 0.25
Nodes (7): A07_Register — Backend, API 엔드포인트, 가입 로직 흐름, 요청 스키마 (Pydantic), 의존성 (공통 유틸), 참고, 파일 구조
### Community 28 - "B06 배수관 구조물 조작·표시"
Cohesion: 0.25
Nodes (7): 4축 조작과 재질, B06 배수관 구조물 조작·표시, 계산·보기 분리, 구현 파일, 자체검증 기록, 조정창, 집수정 9키 조작
### Community 29 - "B06 기슭막이 연동·경사·단별 제어"
Cohesion: 0.25
Nodes (7): B06 기슭막이 연동·경사·단별 제어, 검증, 단별 구간값, 선택과 하이라이트, 연동과 경사, 정본과 공용 모델, 형태와 조정창
### Community 30 - "B08_DesignDetail — Backend"
Cohesion: 0.25
Nodes (7): B08_DesignDetail — Backend, 도각 템플릿 변환 및 종단도 A1 도각 병합 (2026-07-26), 도면 관리, 종단 30측점 분할, CAD 수량산출표 및 도면 템플릿 파이프라인, 워크플로우 게이팅 연동, 종단도 30측점 N분할 및 납품 양식 측점 테이블 (2026-07-25 N-1-1), 현재 책임 경계, 횡단도 4개 선별 레이어 및 CAD 수량산출표 (2026-07-25 N-1-2/N-1-3)
### Community 31 - "B08 토적도·수리집수면적유역도"
Cohesion: 0.25
Nodes (7): B08 토적도·수리집수면적유역도, 검증 한계, 구현·검증 완료, 남은 결정, 데이터 흐름, 도면 기준, 유역 정보표
### Community 32 - "B09_Estimation — Frontend"
Cohesion: 0.25
Nodes (7): B09_Estimation — Frontend, 로컬라이제이션, 백엔드/DB — 미착수, 의존성, 참고, 컴포넌트 / 함수, 파일 구조
### Community 33 - "현재 구현 현황 — 소스 읽기 감사"
Cohesion: 0.06
Nodes (28): 구현 상태 용어, 단계별 판정, 미결 설계, 반드시 유지할 구분, 비워크플로 영역 판정, 현재 구현 현황 — 소스 읽기 감사, Aislo 프로젝트 지도, 명칭 판정 (+20 more)
### Community 34 - "DB: 구조물/수량/산출물 테이블"
Cohesion: 0.29
Nodes (6): DB: 구조물/수량/산출물 테이블, output_files (개별 산출 파일 리스트), outputs (최종 견적/도면 산출 세션), quantity_items (수량 산출 항목), quantity_items 총비용 계산 예, structures (배치 구조물)
### Community 35 - "B01_Dashboard — Backend"
Cohesion: 0.29
Nodes (6): B01_Dashboard — Backend, 기술부채, 라우터 권한 헬퍼, 세분화 백엔드 위키 명세, 요청 스키마, 저장소 및 삭제 함수
### Community 36 - "B01_Dashboard — Frontend"
Cohesion: 0.29
Nodes (6): B01_Dashboard — Frontend, UI 권한 헬퍼, 공유 자원 연결, 모달, 분할된 UI 컴포넌트 파일, 파일과 진입점
### Community 37 - "B03_FileInput — Backend"
Cohesion: 0.29
Nodes (6): B03_FileInput — Backend, workflow·알림, 메타데이터 분석 및 파일 지문, 임시 보관함 (R2 Temp Upload), 입력 검증·파일 처리, 저장소 및 초기화
### Community 38 - "B05 급선회 3D 국부 보정 계획"
Cohesion: 0.29
Nodes (6): B05 급선회 3D 국부 보정 계획, 구조물 연동, 국부 패치 절차, 목적과 경계, 완료 조건, 확인된 노견 확장 회귀
### Community 39 - "B06 집수정·다단 기슭막이"
Cohesion: 0.29
Nodes (6): B06 집수정·다단 기슭막이, 미결, 유입 구조물, 유출 성토부·다단, 자체검증 기록, 집수정 계류측 성토부
### Community 40 - "B06 배수관 구조물 기하·조작 체계"
Cohesion: 0.29
Nodes (6): B06 배수관 구조물 기하·조작 체계, 검증 상태, 구현 파일, 기슭막이·관 핵심 규칙, 설계선 트림, 접속선
### Community 41 - "B06 배수관 횡단도 세트"
Cohesion: 0.29
Nodes (6): B06 배수관 횡단도 세트, 검증 근거와 후속, 계획선 규칙, 구현 항목, 입력·판정, 형상·표시 순서 (2026-08-20 스냅샷)
### Community 42 - "B10_Payment — Frontend"
Cohesion: 0.29
Nodes (6): B10_Payment — Frontend, 로컬라이제이션, 비즈니스 로직 전제 (목업), 의존성, 주요 컴포넌트 및 함수 (Mockup), 파일 구조
### Community 43 - "B11_Status — Frontend"
Cohesion: 0.29
Nodes (6): B11_Status — Frontend, 결재 상태 흐름 (Payment Flow Status), 로컬라이제이션, 의존성, 주요 컴포넌트 및 기능 (Mockup), 파일 구조
### Community 44 - "저장 경로 규칙 (Workflow-based Folder Structure)"
Cohesion: 0.29
Nodes (6): B05 구조물 3D 투영 커브, 검증, 구조물 날개·바닥 연결, 비탈 투영·성토면 절단, 저장·호환성, 커브 생성·렌더
### Community 45 - "DB: 프로젝트 관리 테이블"
Cohesion: 0.33
Nodes (5): DB: 프로젝트 관리 테이블, project_automations (프로젝트 자동화 정책), project_versions (프로젝트 버전 스냅샷), project_workflow_stages (단계별 상세 상태), projects (프로젝트)
### Community 46 - "임도기술교본 원문 md 추출 품질 결함"
Cohesion: 0.05
Nodes (33): 2026-09-09 완료 구현·검증, B05·B06 종횡단, B07 표준도, B08 수량산출, B09 원가계산, 검증 경계, 결함 유형 (예시 = 위 파일 기준 줄번호), 원인 추정 (+25 more)
### Community 47 - "A01_Home — 세부 구현"
Cohesion: 0.33
Nodes (5): A01_Home — 세부 구현, 데이터 흐름, 미해결, 스타일, 의존성
### Community 48 - "A02_ProgDetail — 세부 구현"
Cohesion: 0.33
Nodes (5): A02_ProgDetail — 세부 구현, 데이터 흐름, 미해결 / 특이사항, 스타일 (CSS), 의존성
### Community 49 - "A02_ProgDetail — Frontend"
Cohesion: 0.33
Nodes (5): A02_ProgDetail — Frontend, 구조, 세부 구현, 제약 준수, 컴포넌트 분석
### Community 50 - "B01_Dashboard — API"
Cohesion: 0.33
Nodes (5): B01_Dashboard — API, 사용자·회사, 시스템 관리자, 프로젝트·자동화, 회사 관리자
### Community 51 - "B03_FileInput — Frontend"
Cohesion: 0.33
Nodes (5): API 클라이언트, B03_FileInput — Frontend, UI 지원 유틸리티 (분할 완료), 브라우저 상태·오프라인 보조, 페이지·업로드 흐름
### Community 52 - "B05 계획노선 코리도 삼각망 서피스"
Cohesion: 0.33
Nodes (5): B05 계획노선 코리도 삼각망 서피스, 검증, 저장·호환성, 진행 중 계획, 프론트엔드 구성
### Community 53 - "B05 구조물·UI 현재 계획"
Cohesion: 0.33
Nodes (5): 2026-08-18 B05 페이지 개선 2차 계획 기록, 2026-08-18 사이드 패널·입력 로직 계획 기록, B05/B06 구조물 적용 범위, B05 구조물·UI 현재 계획, 후속 결정 대기
### Community 54 - "B05 구조물 입력·종단 표시 정비 — 2026-08-19"
Cohesion: 0.33
Nodes (5): B05 구조물 입력·종단 표시 정비 — 2026-08-19, 기록된 검증, 기준 해석 보류, 완료 범위, 주요 항목
### Community 55 - "B07 구조물 표준도 — 조사·합의와 현재 통로"
Cohesion: 0.29
Nodes (6): B05_Profile — Frontend, 남은 파일 한계, 📂 소스코드 1:1 세분화 위키 파일 목록, 종단 편집 안전장치, 종단테이블 표시 보정, 📋 핵심 프론트엔드 아키텍처 개요
### Community 56 - "B08 횡단도 구조물·장 배치"
Cohesion: 0.33
Nodes (5): B08 횡단도 구조물·장 배치, 결과, 데이터·구현 흐름, 문제와 결정, 한계
### Community 57 - "2026-09-07 완료·보류 요약"
Cohesion: 0.14
Nodes (11): 2026-09-07 완료·보류 요약, 성능·운영 결정, 완료 범위, 주의, B06_Section — Backend, 계산 엔진, 데이터 영구 저장 및 환경설정, 라우터·workflow (조회, 확정, 타 프로젝트 불러오기 및 재생성) (+3 more)
### Community 58 - "B07 외부 WebCAD 비교 실행환경"
Cohesion: 0.40
Nodes (4): B07 외부 WebCAD 비교 실행환경, 검증 상태, 라이선스 주의, 실행과 종료
### Community 59 - "Temp Upload (프로젝트 생성 전 임시 보관함)"
Cohesion: 0.40
Nodes (4): Temp Upload (프로젝트 생성 전 임시 보관함), 사용처, 주요 개념 및 스펙, 주요 구성 요소
### Community 60 - "Q: 배수관 매설시 각도의 제약조건이 있는지 확인해줘. 임도에서"
Cohesion: 0.40
Nodes (4): Answer, Outcome, Q: 배수관 매설시 각도의 제약조건이 있는지 확인해줘. 임도에서, Source Nodes
### Community 61 - "Q: 임도 기술정보DB에서 집수정의 형태정보는 어떤게 있는지 확인해줘."
Cohesion: 0.40
Nodes (4): Answer, Outcome, Q: 임도 기술정보DB에서 집수정의 형태정보는 어떤게 있는지 확인해줘., Source Nodes
### Community 62 - "B02_ProjRegister — DB"
Cohesion: 0.40
Nodes (4): B02_ProjRegister — DB, 쓰는 테이블, 저장소(파일시스템), 참고 (계획 당시 의도)
### Community 63 - "B03_FileInput — API"
Cohesion: 0.40
Nodes (4): B03_FileInput — API, workflow 조회, 일반 업로드, 청크 업로드
### Community 64 - "B04_PreProcess — DB"
Cohesion: 0.40
Nodes (4): B04_PreProcess — DB, Repository 함수, 쓰는 테이블, 참고
### Community 65 - "B05_Profile — API"
Cohesion: 0.40
Nodes (4): API 스키마 및 반환 필드, B05_Profile — API, `POST /{project_id}/route/confirm` 요청 (`RouteConfirmRequest`), 엔드포인트
### Community 66 - "B05 변형 성토면 마감·날개 패치"
Cohesion: 0.40
Nodes (4): B05 변형 성토면 마감·날개 패치, 세월교 날개 패치, 저장·검증, 패치 마감
### Community 67 - "B05_Profile — Profile Alignment & Table"
Cohesion: 0.40
Nodes (4): 12행 도면 테이블 및 가로 스크롤 정렬 개편 (`_UI_Profile_Table.ts`, `_UI_Profile_Panel.ts`), B05_Profile — Profile Alignment & Table, 비정규 측점(구조물) 테이블 오버레이 & 런타임 검증 (`_UI_Profile_Table.ts`, `_UI_IrregularStations.ts`), 종단 계획고 편집 인터랙션 (`_UI_Profile_Edit.ts`, `_UI_Profile_Panel.ts`, `_UI_Page.ts`)
### Community 68 - "B05 구조물 비정규 측점 공급"
Cohesion: 0.40
Nodes (4): B05 구조물 비정규 측점 공급, 검증, 공급 경로, 정본 규칙
### Community 69 - "B05_Profile_UI_Drainage_Parts — 배수유역 공용 UI 파츠"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, B05_Profile_UI_Drainage_Parts — 배수유역 공용 UI 파츠
### Community 70 - "_UI_Drainage_Render — 배수유역도 Canvas 렌더러"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, _UI_Drainage_Render — 배수유역도 Canvas 렌더러
### Community 71 - "_UI_Profile_Structures — 종단 구조물 렌더링 및 인터랙션"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, _UI_Profile_Structures — 종단 구조물 렌더링 및 인터랙션
### Community 72 - "_UI_Selection — 배수/구조물 3자 선택 동기화"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, _UI_Selection — 배수/구조물 3자 선택 동기화
### Community 73 - "B06 인접 측점 구조물 트림 정리"
Cohesion: 0.40
Nodes (4): B06 인접 측점 구조물 트림 정리, 검증, 원인과 경계, 처리 항목
### Community 74 - "B06 물넘이포장·콘크리트 포장·독립 기슭막이"
Cohesion: 0.40
Nodes (4): B06 물넘이포장·콘크리트 포장·독립 기슭막이, 검증, 독립 기슭막이, 포장과 물넘이
### Community 75 - "B06_Section_Engine_Areas — 횡단 면적 적분 연산 엔진"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, B06_Section_Engine_Areas — 횡단 면적 적분 연산 엔진
### Community 76 - "B06_Section_Router_Confirm — 임시 저장 및 확정 라우터"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 엔드포인트, 3. 의존성, B06_Section_Router_Confirm — 임시 저장 및 확정 라우터
### Community 77 - "B06_Section_UI_Cross_Areas — 횡단 단면적 표기 및 밴드 하이라이트"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, B06_Section_UI_Cross_Areas — 횡단 단면적 표기 및 밴드 하이라이트
### Community 78 - "B06_Section_UI_Cross_Design — 횡단 측점별 세부 설계 컨트롤"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, B06_Section_UI_Cross_Design — 횡단 측점별 세부 설계 컨트롤
### Community 79 - "B06_Section_UI_MassHaul — 유토곡선 적분 계산 엔진"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, B06_Section_UI_MassHaul — 유토곡선 적분 계산 엔진
### Community 80 - "B06_Section_UI_MassHaul_Balance — 평형선 및 장비 띠 분할 엔진"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 알고리즘, 3. 의존성, B06_Section_UI_MassHaul_Balance — 평형선 및 장비 띠 분할 엔진
### Community 81 - "B06_Section_UI_MassHaul_Balance_View — 운반 띠 시각화 렌더러"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 렌더링, 3. 의존성, B06_Section_UI_MassHaul_Balance_View — 운반 띠 시각화 렌더러
### Community 82 - "B06_Section_UI_MassHaul_Balloon — 물량 말풍선 배치 및 조작"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 알고리즘, 3. 의존성, B06_Section_UI_MassHaul_Balloon — 물량 말풍선 배치 및 조작
### Community 83 - "B06_Section_UI_MassHaul_Curve — 유토곡선 궤적 보간 및 렌더링"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 기하 수학, 3. 의존성, B06_Section_UI_MassHaul_Curve — 유토곡선 궤적 보간 및 렌더링
### Community 84 - "B06_Section_UI_MassHaul_Settle — 토량 정산 및 장거리 상쇄 엔진"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 로직, 3. 의존성, B06_Section_UI_MassHaul_Settle — 토량 정산 및 장거리 상쇄 엔진
### Community 85 - "B06_Section_UI_MassHaul_View — 유토곡선 시각화 렌더러"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, B06_Section_UI_MassHaul_View — 유토곡선 시각화 렌더러
### Community 86 - "B06_Section_UI_Page — B06 메인 페이지 오케스트레이터"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, B06_Section_UI_Page — B06 메인 페이지 오케스트레이터
### Community 87 - "B06_Section_UI_Section_View — 종/횡단 및 유토곡선 뷰 조립"
Cohesion: 0.40
Nodes (4): 1. 개요 및 역할, 2. 주요 기능 및 함수, 3. 의존성, B06_Section_UI_Section_View — 종/횡단 및 유토곡선 뷰 조립
### Community 88 - "B07 Quantity — Backend"
Cohesion: 0.40
Nodes (4): B07 Quantity — Backend, 구현되지 않은 항목, 구현된 항목, 책임 경계 미결
### Community 89 - "B08 CAD 블록 라이브러리·사진"
Cohesion: 0.40
Nodes (4): B08 CAD 블록 라이브러리·사진, 검증, 구현, 범위 결정
### Community 90 - "B08 OpenWebCAD 명령 체계"
Cohesion: 0.40
Nodes (4): B08 OpenWebCAD 명령 체계, 검증·제한, 구현 범위, 핵심 구성
### Community 91 - "B08_CAD_table_entity.md"
Cohesion: 0.33
Nodes (5): B08 CAD TableEntity, 검증·후속, 기능, 모델, 이관 범위
### Community 92 - "공개·인증·관리 영역 지도"
Cohesion: 0.29
Nodes (6): B09 원가계산 — 2026-09-09 완료 근거, 가격·조건 입력, 검증 기록, 기계·제비율·산출물, 남은 경계, 단가·밑수 완성
### Community 93 - "공유 자원 영향 지도"
Cohesion: 0.33
Nodes (5): B08 수량산출 — 2026-09-09 완료 근거, 검증 기록, 구조물·수량 표시, 남은 경계, 밑수와 인계
### Community 94 - "A00_Common — 공통 프레임워크 & 유틸"
Cohesion: 0.50
Nodes (3): A00_Common — 공통 프레임워크 & 유틸, 📋 개요, 📂 세분화 마크다운 문서 목록
### Community 95 - "app_shell.ts"
Cohesion: 0.50
Nodes (3): app_shell.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 96 - "router.ts"
Cohesion: 0.50
Nodes (3): router.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 97 - "B04_PreProcess — API"
Cohesion: 0.50
Nodes (3): B04_PreProcess — API, 엔드포인트, 요청/응답 스키마 (Pydantic)
### Community 98 - "B04_PreProcess — Dependencies"
Cohesion: 0.50
Nodes (3): B04_PreProcess — Dependencies, 백엔드 (Python), 프론트엔드 (TypeScript)
### Community 99 - "B05_Profile — Backend"
Cohesion: 0.50
Nodes (3): B05_Profile — Backend, 📂 소스코드 1:1 세분화 위키 파일 목록, 📋 핵심 백엔드 아키텍처 개요
### Community 100 - "B05_Profile — DB 사용 관계"
Cohesion: 0.50
Nodes (3): B05_Profile — DB 사용 관계, Repository 함수, 저장 경로
### Community 101 - "B05_Profile — 3D Viewer & Interaction"
Cohesion: 0.50
Nodes (3): 3D 마커 직접 드래그 이동 (0_old I-401 이식), 3D 지형 뷰포트 시각화 (`_UI_Viewer.ts`, `_UI_Markers.ts`), B05_Profile — 3D Viewer & Interaction
### Community 102 - "B05_Profile_Engine_Sections.md"
Cohesion: 0.40
Nodes (4): B05_Profile_Engine_Sections.py, ⚠️ 런타임 검증 주의사항 (2026-07-24 검증 보고서 기준), 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 103 - "B05_Profile_Api_Fetch.ts"
Cohesion: 0.50
Nodes (3): B05_Profile_Api_Fetch.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 API 함수 목록
### Community 104 - "B05_Profile_UI_Drainage_Panel"
Cohesion: 0.50
Nodes (3): B05_Profile_UI_Drainage_Panel, 📋 개요 및 특징, 🛠️ 주요 함수 / 심볼 목록
### Community 105 - "B05_Profile_UI_Drainage_Pipes"
Cohesion: 0.50
Nodes (3): B05_Profile_UI_Drainage_Pipes, 📋 개요 및 특징, 🛠️ 주요 함수 / 심볼 목록
### Community 106 - "B05_Profile_UI_Profile_Panel.md"
Cohesion: 0.40
Nodes (4): B05_Profile_UI_Profile_Panel.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 기능 및 개선사항 (2026-08-06), 🛠️ 주요 함수 목록
### Community 107 - "B06_Section — DB 사용 관계"
Cohesion: 0.50
Nodes (3): B06_Section — DB 사용 관계, Repository 함수, 파일 경로
### Community 108 - "B06_Section_Api_Fetch — B06 프론트엔드 API 클라이언트"
Cohesion: 0.50
Nodes (3): 1. 개요 및 역할, 2. 주요 연동 API 함수, B06_Section_Api_Fetch — B06 프론트엔드 API 클라이언트
### Community 109 - "B06_Section_UI_Standard_Diagram — 표준단면 모식도 컴포넌트"
Cohesion: 0.50
Nodes (3): 1. 개요 및 역할, 2. 주요 기능, B06_Section_UI_Standard_Diagram — 표준단면 모식도 컴포넌트
### Community 110 - "B06_Section_UI_Standard_Panel — 표준단면 입력 및 제어 패널"
Cohesion: 0.50
Nodes (3): 1. 개요 및 역할, 2. 주요 기능, B06_Section_UI_Standard_Panel — 표준단면 입력 및 제어 패널
### Community 111 - "B08_DesignDetail — Dependencies"
Cohesion: 0.50
Nodes (3): B08_DesignDetail — Dependencies, Backend (requirements.txt), Frontend (package.json / tsconfig.json)
### Community 112 - "common_util_project_delete.md"
Cohesion: 0.50
Nodes (3): common_util_project_delete.py, 🔗 역참조 (사용처), 🛠️ 주요 함수 목록
### Community 113 - "common_util_storage.md"
Cohesion: 0.50
Nodes (3): common_util_storage.py, 🔗 역참조 (사용처), 🛠️ 주요 함수 목록
### Community 114 - "common_util_workflow_state.md"
Cohesion: 0.50
Nodes (3): common_util_workflow_state.py, 🔗 역참조 (사용처), 🛠️ 주요 함수 목록
### Community 116 - "A01_Home_UI_Page.md"
Cohesion: 0.50
Nodes (3): A01_Home_UI_Page.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 및 인터페이스 목록
### Community 117 - "A02_ProgDetail_UI_Page.md"
Cohesion: 0.50
Nodes (3): A02_ProgDetail_UI_Page.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 118 - "A06_Login_Router.md"
Cohesion: 0.50
Nodes (3): A06_Login_Router.py, 🛠️ 라우터 API 및 주요 헬퍼 함수 목록, 🔗 연관 개념 및 의존성
### Community 119 - "A07_Register_Router.md"
Cohesion: 0.50
Nodes (3): A07_Register_Router.py, 🛠️ 라우터 API 및 주요 함수 목록, 🔗 연관 개념 및 의존성
### Community 120 - "A08_Support_Router.md"
Cohesion: 0.50
Nodes (3): A08_Support_Router.py, 🛠️ 라우터 API 및 주요 함수 목록, 🔗 연관 개념 및 의존성
### Community 121 - "A09_Security_Router.md"
Cohesion: 0.50
Nodes (3): A09_Security_Router.py, 🛠️ 라우터 API 및 주요 함수 목록, 🔗 연관 개념 및 의존성
### Community 124 - "B01_Dashboard_Router.md"
Cohesion: 0.50
Nodes (3): B01_Dashboard_Router.py, 🛠️ 라우터 API 및 주요 함수 목록, 🔗 연관 개념 및 의존성
### Community 125 - "B01_Dashboard_UI_Page.md"
Cohesion: 0.50
Nodes (3): B01_Dashboard_UI_Page.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 126 - "B02_ProjRegister_Router.md"
Cohesion: 0.50
Nodes (3): B02_ProjRegister_Router.py, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 129 - "B03_FileInput_Router.md"
Cohesion: 0.50
Nodes (3): B03_FileInput_Router.py, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 130 - "B04_PreProcess_Router.md"
Cohesion: 0.50
Nodes (3): B04_PreProcess_Router.py, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 132 - "B05_Profile_Engine_Grade.md"
Cohesion: 0.50
Nodes (3): B05_Profile_Engine_Grade.py, 🔗 연관 개념 및 의존성, 🛠️ 주요 클래스 및 함수 목록
### Community 133 - "B05_Profile_Engine_Solver.md"
Cohesion: 0.50
Nodes (3): B05_Profile_Engine_Solver.py, 🛠️ 엔진 핵심 함수 목록, 🔗 연관 개념 및 의존성
### Community 134 - "B05_Profile_Repository.md"
Cohesion: 0.50
Nodes (3): B05_Profile_Repository.py, 🛠️ DB 접근 함수 목록, 🔗 연관 개념 및 의존성
### Community 135 - "B05_Profile_Router.md"
Cohesion: 0.50
Nodes (3): B05_Profile_Router.py, 🛠️ 라우터 API 및 주요 함수 목록, 🔗 연관 모듈 및 의존성
### Community 136 - "B05_Profile_Router_Confirm.md"
Cohesion: 0.50
Nodes (3): B05_Profile_Router_Confirm.py, 🔗 연관 모듈, 🛠️ 주요 헬퍼 함수 목록
### Community 137 - "B05_Profile_Schema.md"
Cohesion: 0.50
Nodes (3): B05_Profile_Schema.py, 🛠️ Pydantic 모델 및 검증 헬퍼 목록, 🔗 연관 개념 및 의존성
### Community 138 - "B05_Profile_UI_IrregularStations.md"
Cohesion: 0.50
Nodes (3): B05_Profile_UI_IrregularStations.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 인터페이스 및 함수 목록
### Community 139 - "B05_Profile_UI_Page.md"
Cohesion: 0.50
Nodes (3): B05_Profile_UI_Page.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 컴포넌트 및 함수 목록
### Community 140 - "B05_Profile_UI_Panel.md"
Cohesion: 0.50
Nodes (3): B05_Profile_UI_Panel.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 141 - "B05_Profile_UI_Profile_Alignment.md"
Cohesion: 0.50
Nodes (3): B05_Profile_UI_Profile_Alignment.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 142 - "B05_Profile_UI_Profile_Table.md"
Cohesion: 0.50
Nodes (3): B05_Profile_UI_Profile_Table.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 143 - "B05_Profile_UI_Viewer.md"
Cohesion: 0.50
Nodes (3): B05_Profile_UI_Viewer.ts, 🔗 연관 개념 및 의존성, 🛠️ 주요 함수 목록
### Community 145 - "B06_Section_Router.md"
Cohesion: 0.50
Nodes (3): B06_Section_Router.py, 🛠️ 라우터 API 및 주요 함수 목록, 🔗 연관 개념 및 의존성
### Community 146 - "B01~B09 Workflow 데이터 흐름"
Cohesion: 0.40
Nodes (4): 3D 방위와 좌표계, B04 세부유역·방위·좌표계, 세부유역 형상 보존, 종단 높낮이 기반 배정
### Community 158 - "B03 계획노선 정본·좌표계 후속"
Cohesion: 0.40
Nodes (4): B05 구조물 구간 절취·측벽·성토 패치, B06 변형 성토선 패치, 저장·검증, 절취와 경계
### Community 159 - "B03 파일 입력 화면 정리"
Cohesion: 0.40
Nodes (4): B06 횡단 관 형상·유토곡선 후속, I형 집수정 관 형상, 계산 상태와 경고, 파일 책임 분리
### Community 160 - "B04 3D 방위·좌표계 최신 결정"
Cohesion: 0.40
Nodes (4): B08 CAD 기본 조작, 검증·제외, 선택·편집, 입력·상태
### Community 161 - "B04 세부유역·방위·좌표계"
Cohesion: 0.40
Nodes (4): B08 CAD 도각·표제란, 값 공급, 도각 편집·보존, 회사 자산과 담당자
### Community 162 - "B05 구조물 구간 절취·측벽·성토 패치"
Cohesion: 0.40
Nodes (4): B08 CAD 사용자 편의성 정리, 검증 상태, 구현 기록, 사용자 흐름
### Community 163 - "B05 구조물 3D 투영 커브"
Cohesion: 0.50
Nodes (3): B03 계획노선 정본·좌표계 후속, LAS 없는 설계와 업로드 상태, 계획노선 정본
### Community 164 - "B05_Profile — Frontend"
Cohesion: 0.50
Nodes (3): B03 파일 입력 화면 정리, 입력 규칙, 화면 구성
### Community 165 - "B05 유토곡선·구조물 후속"
Cohesion: 0.50
Nodes (3): 3D 방위 위젯, B04 3D 방위·좌표계 최신 결정, 작업 좌표계
### Community 166 - "B05 종단곡선·실시간 횡단 연동"
Cohesion: 0.50
Nodes (3): B05 유토곡선·구조물 후속, 유지·판정 사항, 유토곡선 공용화
### Community 167 - "B06 횡단 계산 미러·카드 표기"
Cohesion: 0.50
Nodes (3): B05 종단곡선·실시간 횡단 연동, 실시간 횡단·유토곡선, 초기 종단곡선
### Community 168 - "B06 횡단 관 형상·유토곡선 후속"
Cohesion: 0.50
Nodes (3): B06 횡단 계산 미러·카드 표기, 프론트 계산 미러, 횡단 카드 표기
### Community 171 - "B08 CAD·납품 도면 후속"
Cohesion: 0.50
Nodes (3): B08 CAD·납품 도면 후속, CAD 편집·확정, 토적도·유역도
### Community 182 - "2026-09-02 완료 항목"
Cohesion: 0.25
Nodes (7): 2026-09-02 완료 항목, B05 구조물 3D, B05 종단 편집 후속, CAD, 노선·지표면, 작업 환경, 회귀 상태
### Community 183 - "2026-09-03 완료 — 입력·배수·종횡단·CAD"
Cohesion: 0.50
Nodes (3): 2026-09-03 완료 — 입력·배수·종횡단·CAD, 공통 결정, 완료 범위
### Community 184 - "2026-09-03 추가 완료 — 화면·종단·횡단"
Cohesion: 0.50
Nodes (3): 2026-09-03 추가 완료 — 화면·종단·횡단, 완료 범위, 최신 결정
## Knowledge Gaps
- **756 isolated node(s):** `단계별 판정`, `비워크플로 영역 판정`, `구현 상태 용어`, `반드시 유지할 구분`, `미결 설계` (+751 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **34 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Work-memory lessons
**Known dead ends** — questions that led nowhere; don't re-derive.
- "배수관 매설시 각도의 제약조건이 있는지 확인해줘. 임도에서" -> `배수유역 해석 및 세부설계`
- "임도 기술정보DB에서 집수정의 형태정보는 어떤게 있는지 확인해줘." -> `B06 배수관 횡단도 세트`, `유입 구조물 판정`
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **What connects `단계별 판정`, `비워크플로 영역 판정`, `구현 상태 용어` to the rest of the system?**
_756 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `UI Templates — Localization & Components` be split into smaller, more focused modules?**
_Cohesion score 0.046511627906976744 - nodes in this community are weakly interconnected._
- **Should `인증 / RBAC` be split into smaller, more focused modules?**
_Cohesion score 0.05555555555555555 - nodes in this community are weakly interconnected._
- **Should `A00_Common — App Shell Framework` be split into smaller, more focused modules?**
_Cohesion score 0.07692307692307693 - nodes in this community are weakly interconnected._
- **Should `2026-09-04 완료 항목` be split into smaller, more focused modules?**
_Cohesion score 0.06896551724137931 - nodes in this community are weakly interconnected._
- **Should `2026-08-29 완료 반영` be split into smaller, more focused modules?**
_Cohesion score 0.1 - nodes in this community are weakly interconnected._
- **Should `현재 구현 현황 — 소스 읽기 감사` be split into smaller, more focused modules?**
_Cohesion score 0.058823529411764705 - nodes in this community are weakly interconnected._
@@ -0,0 +1,51 @@
{
"runs": [
{
"date": "2026-08-16T00:00:00+09:00",
"input_tokens": 265124,
"output_tokens": 14991,
"files": 146
},
{
"date": "2026-08-16T12:43:20.106802+00:00",
"input_tokens": 27985,
"output_tokens": 2184,
"files": 22
},
{
"date": "2026-08-16T12:46:45.166477+00:00",
"input_tokens": 5694,
"output_tokens": 1138,
"files": 6
},
{
"date": "2026-08-16T12:48:56.020124+00:00",
"input_tokens": 14784,
"output_tokens": 2835,
"files": 12
},
{
"date": "2026-08-16T14:33:37.811664+00:00",
"input_tokens": 0,
"output_tokens": 0,
"files": 12,
"usage_note": "semantic subagent token usage unavailable from collaboration runtime; graph content extracted and validated"
},
{
"date": "2026-08-20T11:01:22.299635+00:00",
"input_tokens": 0,
"output_tokens": 0,
"files": 10,
"usage_note": "Gemini backend dependency unavailable; host semantic extraction used and collaboration runtime token usage unavailable; 27 nodes/59 edges validated"
},
{
"date": "2026-08-21T09:54:12.581221+00:00",
"input_tokens": 0,
"output_tokens": 0,
"files": 3,
"note": "collaboration runtime did not expose semantic extraction token usage"
}
],
"total_input_tokens": 313587,
"total_output_tokens": 21148
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+55 -23
View File
@@ -1,16 +1,16 @@
# Graph Report - wiki (2026-09-11)
# Graph Report - wiki (2026-09-12)
## Corpus Check
- 206 files · ~57,686 words
- 207 files · ~58,032 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1170 nodes · 996 edges · 189 communities (155 shown, 34 thin omitted)
- 1176 nodes · 1003 edges · 196 communities (162 shown, 34 thin omitted)
- Extraction: 100% EXTRACTED · 0% INFERRED · 0% AMBIGUOUS
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `e49e4d85`
- Built from commit: `d6e45bb4`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -187,14 +187,21 @@
- B08_DesignDetail_Engine_Cad_Basin.py
- B08_DesignDetail_Engine_Cad_MassHaul.py
- B08 CAD·납품 도면 후속
- 2026-09-04 완료 항목
- multi_environment_safety.md
- Workflow 상태 관리
- DB 스키마 개요
- OpenWebCAD Core
- common_util_mass_haul_settle.ts
- Drainage Watershed (유역도)
- Mass Haul Diagram (토적도)
- 다중 환경 저장소·공용 DB 안전
- 저장 경로 규칙 (Workflow-based Folder Structure)
- 2026-09-02 완료 항목
- 2026-09-03 완료 — 입력·배수·종횡단·CAD
- 2026-09-03 추가 완료 — 화면·종단·횡단
- Query: 임도 집수정 형태정보
- 설계 데이터 생명주기
- B03 File Input Backend
- B03 File Input Frontend
- B04 PreProcess Backend
@@ -229,9 +236,6 @@
- None detected.
## Hyperedges (group relationships)
- **B03-B08 Workflow Data Flow** — b03_fileinput_route_snapshot_crs, b04_preprocess_drainage_compass_crs, b05_profile_frontend, b06_section_cross_design_ui_2026_09, b08_designdetail_frontend [INFERRED 0.90]
- **Shared Mass Haul Calculation and UI** — b05_profile_masshaul_structure_2026_09, b06_section_masshaul_culvert_2026_09, b08_designdetail_cad_delivery_2026_09 [EXTRACTED 0.85]
- **CAD Delivery and Usability Framework** — b08_designdetail_cad_interaction, b08_designdetail_cad_title_block, b08_designdetail_cad_usability_2026_09_01, b08_designdetail_cad_delivery_2026_09 [EXTRACTED 0.95]
- **LAS-Free Analysis Workflow** — concepts_las_free_sheet_surface, pages_b03_fileinput_backend, pages_b04_preprocess_backend [EXTRACTED 1.00]
- **B08 Drawing Generation Flow** — b08_designdetail_b08_designdetail_engine_cad_masshaul_py, b08_designdetail_b08_designdetail_engine_cad_basin_py, common_util_common_util_mass_haul_settle_ts [EXTRACTED 0.90]
- **Drainage System Workflow** — concepts_drainage_watershed, pages_b05_profile_b05_structures, pages_b06_section_b06_culvert_set, pages_b06_section_b06_culvert_geometry_redesign [EXTRACTED 0.95]
@@ -239,23 +243,23 @@
- **3D Corridor Generation Flow** — pages_b05_profile_b05_corridor_surface, pages_b05_profile_b05_corridor_plan_curves, pages_b05_profile_b05_corridor_cut_fill, pages_b05_profile_b05_corridor_patch_finish [EXTRACTED 0.90]
- **Late Workflow Stages (B07-B09)** — pages_b07_quantity_b07_frontend, pages_b08_designdetail_b08_frontend, pages_b09_estimation_b09_frontend [EXTRACTED 1.00]
## Communities (189 total, 34 thin omitted)
## Communities (196 total, 34 thin omitted)
### Community 0 - "UI Templates — Localization & Components"
Cohesion: 0.05
Nodes (38): 2026-09-04 완료 항목, 700줄 제한 분리, 보존된 미완료 범위, 상시계획서 추가 완료 범위, 완료 범위, 추가 완료 범위, 디자인 시스템 (Design System), 레이아웃 및 둥근 테두리 (Radius & Spacing) (+30 more)
Cohesion: 0.10
Nodes (18): 디자인 시스템 (Design System), 레이아웃 및 둥근 테두리 (Radius & Spacing), 비주얼 테마, 전역 스크롤바 디자인 (Scrollbars), 타이포그래피 (Typography), 핵심 색상 토큰 (Colors), theme.css (스타일 변수), ui_template_elements.ts (공통 엘리먼트 템플릿) (+10 more)
### Community 1 - "인증 / RBAC"
Cohesion: 0.06
Nodes (31): OTP / 비밀번호 및 디바이스 신뢰, 권한 검증 헬퍼 (B01_Dashboard), 라우팅 가드 (frontend.md 5.2), 사용자 상태 생명주기, 사용처 (역참조), 세션 인증 (backend.md 6.3), 역할 (users.role), 인증 / RBAC (+23 more)
Cohesion: 0.11
Nodes (17): OTP / 비밀번호 및 디바이스 신뢰, 권한 검증 헬퍼 (B01_Dashboard), 라우팅 가드 (frontend.md 5.2), 사용자 상태 생명주기, 사용처 (역참조), 세션 인증 (backend.md 6.3), 역할 (users.role), 인증 / RBAC (+9 more)
### Community 2 - "A00_Common — App Shell Framework"
Cohesion: 0.08
Nodes (22): A00_Common — App Shell Framework, app_shell 구성요소, router 라우팅 테이블, A00_Common — 스캐폴드·CSS·종속성, b_page_scaffold, CSS 인젝션, 사용처, 종속성 (+14 more)
### Community 3 - "2026-09-04 완료 항목"
Cohesion: 0.07
Nodes (25): API 공통 (여러 페이지가 공유하는 엔드포인트), 공통 오류 응답 포맷 (전 라우터), 워크플로우 상태 조회, 폴링 패턴 (legacy workflow.json 설계; 현재 구현은 workflow-state API 사용), 계산 구현 원칙, 계획노선 규칙, 설계 데이터 생명주기, 정본 세 벌 (+17 more)
Cohesion: 0.22
Nodes (7): API 공통 (여러 페이지가 공유하는 엔드포인트), 공통 오류 응답 포맷 (전 라우터), 워크플로우 상태 조회, 폴링 패턴 (legacy workflow.json 설계; 현재 구현은 workflow-state API 사용), 검증 원칙 (backend.md 4절), 공통 스키마 (Pydantic 요청/응답 규칙), 명명 규칙
### Community 4 - "배수유역 해석 및 세부설계 (Drainage Watershed)"
Cohesion: 0.25
@@ -270,8 +274,8 @@ Cohesion: 0.17
Nodes (11): A09_Security — Backend, API 엔드포인트, DB 저장 (activity_logs), 권한 헬퍼, 마스터(회사 관리자) 전용 — `require_master`, 시스템 관리자 전용 — `require_system_admin`, 요청 스키마 (Pydantic), 의존성 (공통 유틸) (+3 more)
### Community 7 - "2026-08-29 완료 반영"
Cohesion: 0.10
Nodes (17): 2026-08-29 완료 반영, B03 재업로드·B05 최신 조회, B05/B06 구조물 UI 통합, B07↔B08 순서, B07 CAD 고정 척도·횡단 장 배치, B07 CAD 테마, B07 CAD 확대·팬, 배수시설 추천 기준 (+9 more)
Cohesion: 0.06
Nodes (31): 2026-08-29 완료 반영, B03 재업로드·B05 최신 조회, B05/B06 구조물 UI 통합, B07↔B08 순서, B07 CAD 고정 척도·횡단 장 배치, B07 CAD 테마, B07 CAD 확대·팬, 배수시설 추천 기준 (+23 more)
### Community 8 - "DB: 파일/지표면분석 테이블"
Cohesion: 0.18
@@ -849,6 +853,30 @@ Nodes (3): B06 횡단 계산 미러·카드 표기, 프론트 계산 미러, 횡
Cohesion: 0.50
Nodes (3): B08 CAD·납품 도면 후속, CAD 편집·확정, 토적도·유역도
### Community 172 - "2026-09-04 완료 항목"
Cohesion: 0.18
Nodes (9): 공통 유틸 (common_util/), 리소스 모니터링 (common_util_resource_monitor.py), 이메일 발송 (common_util_email.py), 2026-09-04 완료 항목, 700줄 제한 분리, 보존된 미완료 범위, 상시계획서 추가 완료 범위, 완료 범위 (+1 more)
### Community 173 - "multi_environment_safety.md"
Cohesion: 0.32
Nodes (4): 공용 브라우저 시동과 조작, 기본 선택, 변경 종류별 반영, 화면 검증 브라우저 운용
### Community 174 - "Workflow 상태 관리"
Cohesion: 0.25
Nodes (8): R1 워크플로우 단계 재편 (2026-08-08 반영), SSOT: `project_workflow_stages` 테이블 (실 DB 확인), Workflow 상태 관리, 공통 유틸 `common_util/common_util_workflow_state.py`, 무효화의 실제 범위, 백그라운드 자동 계산 체인 및 사용자 설정 이월, 조회 API, 프론트엔드 게이팅 및 스텝바 연동
### Community 175 - "DB 스키마 개요"
Cohesion: 0.29
Nodes (5): DB 스키마 개요, 설계 원칙, 테이블 관계 (핵심 흐름), 테이블 그룹 (9개), 파일 경로 추적 컬럼 (DB에 경로만 기록, 실 파일은 파일시스템)
### Community 180 - "다중 환경 저장소·공용 DB 안전"
Cohesion: 0.29
Nodes (7): Git 한 바퀴, 다중 환경 저장소·공용 DB 안전, 여섯 워크트리 운영 확정판, 완료 판정, 운영 규칙, 재계산 영향, 확인된 위험
### Community 181 - "저장 경로 규칙 (Workflow-based Folder Structure)"
Cohesion: 0.33
Nodes (6): DB 컬럼 ↔ 실제 경로 매핑, 경로 패턴, 원칙 (backend.md 3절), 저장 경로 규칙 (Workflow-based Folder Structure), 코드 감사 주의사항, 파일명 규칙 (structure.md 1절)
### Community 182 - "2026-09-02 완료 항목"
Cohesion: 0.25
Nodes (7): 2026-09-02 완료 항목, B05 구조물 3D, B05 종단 편집 후속, CAD, 노선·지표면, 작업 환경, 회귀 상태
@@ -861,8 +889,12 @@ Nodes (3): 2026-09-03 완료 — 입력·배수·종횡단·CAD, 공통 결정,
Cohesion: 0.50
Nodes (3): 2026-09-03 추가 완료 — 화면·종단·횡단, 완료 범위, 최신 결정
### Community 186 - "설계 데이터 생명주기"
Cohesion: 0.50
Nodes (4): 계산 구현 원칙, 계획노선 규칙, 설계 데이터 생명주기, 정본 세 벌
## Knowledge Gaps
- **756 isolated node(s):** `단계별 판정`, `비워크플로 영역 판정`, `구현 상태 용어`, `반드시 유지할 구분`, `미결 설계` (+751 more)
- **760 isolated node(s):** `단계별 판정`, `비워크플로 영역 판정`, `구현 상태 용어`, `반드시 유지할 구분`, `미결 설계` (+755 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **34 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
@@ -875,17 +907,17 @@ Nodes (3): 2026-09-03 추가 완료 — 화면·종단·횡단, 완료 범위,
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `B07 구조물 표준도 — 조사·합의와 현재 통로` connect `임도기술교본 원문 md 추출 품질 결함` to `현재 구현 현황 — 소스 읽기 감사`, `유토곡선 (Mass Haul Diagram) 계산 명세`?**
_High betweenness centrality (0.003) - this node is a cross-community bridge._
- **What connects `단계별 판정`, `비워크플로 영역 판정`, `구현 상태 용어` to the rest of the system?**
_756 weakly-connected nodes found - possible documentation gaps or missing edges._
_760 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `UI Templates — Localization & Components` be split into smaller, more focused modules?**
_Cohesion score 0.046511627906976744 - nodes in this community are weakly interconnected._
_Cohesion score 0.1 - nodes in this community are weakly interconnected._
- **Should `인증 / RBAC` be split into smaller, more focused modules?**
_Cohesion score 0.05555555555555555 - nodes in this community are weakly interconnected._
_Cohesion score 0.10526315789473684 - nodes in this community are weakly interconnected._
- **Should `A00_Common — App Shell Framework` be split into smaller, more focused modules?**
_Cohesion score 0.07692307692307693 - nodes in this community are weakly interconnected._
- **Should `2026-09-04 완료 항목` be split into smaller, more focused modules?**
_Cohesion score 0.06896551724137931 - nodes in this community are weakly interconnected._
- **Should `2026-08-29 완료 반영` be split into smaller, more focused modules?**
_Cohesion score 0.1 - nodes in this community are weakly interconnected._
_Cohesion score 0.05555555555555555 - nodes in this community are weakly interconnected._
- **Should `현재 구현 현황 — 소스 읽기 감사` be split into smaller, more focused modules?**
_Cohesion score 0.058823529411764705 - nodes in this community are weakly interconnected._
File diff suppressed because one or more lines are too long
+294 -235
View File
@@ -3,48 +3,6 @@
"multigraph": false,
"graph": {
"hyperedges": [
{
"id": "workflow_b03_b08_integration",
"label": "B03-B08 Workflow Data Flow",
"nodes": [
"b03_fileinput_route_snapshot_crs",
"b04_preprocess_drainage_compass_crs",
"b05_profile_frontend",
"b06_section_cross_design_ui_2026_09",
"b08_designdetail_frontend"
],
"relation": "participate_in",
"confidence": "INFERRED",
"confidence_score": 0.9,
"source_file": "index.md"
},
{
"id": "mass_haul_shared_logic",
"label": "Shared Mass Haul Calculation and UI",
"nodes": [
"b05_profile_masshaul_structure_2026_09",
"b06_section_masshaul_culvert_2026_09",
"b08_designdetail_cad_delivery_2026_09"
],
"relation": "participate_in",
"confidence": "EXTRACTED",
"confidence_score": 0.85,
"source_file": "pages/B05_Profile/B05_masshaul_structure_2026_09.md"
},
{
"id": "cad_delivery_system",
"label": "CAD Delivery and Usability Framework",
"nodes": [
"b08_designdetail_cad_interaction",
"b08_designdetail_cad_title_block",
"b08_designdetail_cad_usability_2026_09_01",
"b08_designdetail_cad_delivery_2026_09"
],
"relation": "form",
"confidence": "EXTRACTED",
"confidence_score": 0.95,
"source_file": "pages/B08_DesignDetail/B08_cad_delivery_2026_09.md"
},
{
"id": "las_free_workflow_chain",
"label": "LAS-Free Analysis Workflow",
@@ -798,6 +756,61 @@
"community_name": "B07 \uc678\ubd80 WebCAD \ube44\uad50 \uc2e4\ud589\ud658\uacbd",
"norm_label": "\u1105\u1161\u110b\u1175\u1109\u1165\u11ab\u1109\u1173 \u110c\u116e\u110b\u1174"
},
{
"label": "browser_verification_operations.md",
"file_type": "document",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L1",
"_origin": "ast",
"id": "concepts_browser_verification_operations",
"community": 173,
"community_name": "multi_environment_safety.md",
"norm_label": "browser_verification_operations.md"
},
{
"label": "\ud654\uba74 \uac80\uc99d \ube0c\ub77c\uc6b0\uc800 \uc6b4\uc6a9",
"file_type": "document",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L9",
"_origin": "ast",
"id": "concepts_browser_verification_operations_\ud654\uba74_\uac80\uc99d_\ube0c\ub77c\uc6b0\uc800_\uc6b4\uc6a9",
"community": 173,
"community_name": "multi_environment_safety.md",
"norm_label": "\u1112\u116a\u1106\u1167\u11ab \u1100\u1165\u11b7\u110c\u1173\u11bc \u1107\u1173\u1105\u1161\u110b\u116e\u110c\u1165 \u110b\u116e\u11ab\u110b\u116d\u11bc"
},
{
"label": "\uae30\ubcf8 \uc120\ud0dd",
"file_type": "document",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L11",
"_origin": "ast",
"id": "concepts_browser_verification_operations_\uae30\ubcf8_\uc120\ud0dd",
"community": 173,
"community_name": "multi_environment_safety.md",
"norm_label": "\u1100\u1175\u1107\u1169\u11ab \u1109\u1165\u11ab\u1110\u1162\u11a8"
},
{
"label": "\uacf5\uc6a9 \ube0c\ub77c\uc6b0\uc800 \uc2dc\ub3d9\uacfc \uc870\uc791",
"file_type": "document",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L17",
"_origin": "ast",
"id": "concepts_browser_verification_operations_\uacf5\uc6a9_\ube0c\ub77c\uc6b0\uc800_\uc2dc\ub3d9\uacfc_\uc870\uc791",
"community": 173,
"community_name": "multi_environment_safety.md",
"norm_label": "\u1100\u1169\u11bc\u110b\u116d\u11bc \u1107\u1173\u1105\u1161\u110b\u116e\u110c\u1165 \u1109\u1175\u1103\u1169\u11bc\u1100\u116a \u110c\u1169\u110c\u1161\u11a8"
},
{
"label": "\ubcc0\uacbd \uc885\ub958\ubcc4 \ubc18\uc601",
"file_type": "document",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L29",
"_origin": "ast",
"id": "concepts_browser_verification_operations_\ubcc0\uacbd_\uc885\ub958\ubcc4_\ubc18\uc601",
"community": 173,
"community_name": "multi_environment_safety.md",
"norm_label": "\u1107\u1167\u11ab\u1100\u1167\u11bc \u110c\u1169\u11bc\u1105\u1172\u1107\u1167\u11af \u1107\u1161\u11ab\u110b\u1167\u11bc"
},
{
"label": "common_util.md",
"file_type": "document",
@@ -805,8 +818,8 @@
"source_location": "L1",
"_origin": "ast",
"id": "concepts_common_util",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "common_util.md"
},
{
@@ -816,8 +829,8 @@
"source_location": "L9",
"_origin": "ast",
"id": "concepts_common_util_\uacf5\ud1b5_\uc720\ud2f8_common_util",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "\u1100\u1169\u11bc\u1110\u1169\u11bc \u110b\u1172\u1110\u1175\u11af (common_util/)"
},
{
@@ -827,8 +840,8 @@
"source_location": "L39",
"_origin": "ast",
"id": "concepts_common_util_\uc774\uba54\uc77c_\ubc1c\uc1a1_common_util_email_py",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "\u110b\u1175\u1106\u1166\u110b\u1175\u11af \u1107\u1161\u11af\u1109\u1169\u11bc (common_util_email.py)"
},
{
@@ -838,8 +851,8 @@
"source_location": "L43",
"_origin": "ast",
"id": "concepts_common_util_\ub9ac\uc18c\uc2a4_\ubaa8\ub2c8\ud130\ub9c1_common_util_resource_monitor_py",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "\u1105\u1175\u1109\u1169\u1109\u1173 \u1106\u1169\u1102\u1175\u1110\u1165\u1105\u1175\u11bc (common_util_resource_monitor.py)"
},
{
@@ -1377,8 +1390,8 @@
"source_location": "L1",
"_origin": "ast",
"id": "concepts_completed_2026_09_04",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "completed_2026-09-04.md"
},
{
@@ -1388,8 +1401,8 @@
"source_location": "L9",
"_origin": "ast",
"id": "concepts_completed_2026_09_04_2026_09_04_\uc644\ub8cc_\ud56d\ubaa9",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "2026-09-04 \u110b\u116a\u11ab\u1105\u116d \u1112\u1161\u11bc\u1106\u1169\u11a8"
},
{
@@ -1399,8 +1412,8 @@
"source_location": "L13",
"_origin": "ast",
"id": "concepts_completed_2026_09_04_\uc644\ub8cc_\ubc94\uc704",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "\u110b\u116a\u11ab\u1105\u116d \u1107\u1165\u11b7\u110b\u1171"
},
{
@@ -1410,8 +1423,8 @@
"source_location": "L38",
"_origin": "ast",
"id": "concepts_completed_2026_09_04_\ucd94\uac00_\uc644\ub8cc_\ubc94\uc704",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "\u110e\u116e\u1100\u1161 \u110b\u116a\u11ab\u1105\u116d \u1107\u1165\u11b7\u110b\u1171"
},
{
@@ -1421,8 +1434,8 @@
"source_location": "L48",
"_origin": "ast",
"id": "concepts_completed_2026_09_04_700\uc904_\uc81c\ud55c_\ubd84\ub9ac",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "700\u110c\u116e\u11af \u110c\u1166\u1112\u1161\u11ab \u1107\u116e\u11ab\u1105\u1175"
},
{
@@ -1432,8 +1445,8 @@
"source_location": "L54",
"_origin": "ast",
"id": "concepts_completed_2026_09_04_\uc0c1\uc2dc\uacc4\ud68d\uc11c_\ucd94\uac00_\uc644\ub8cc_\ubc94\uc704",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "\u1109\u1161\u11bc\u1109\u1175\u1100\u1168\u1112\u116c\u11a8\u1109\u1165 \u110e\u116e\u1100\u1161 \u110b\u116a\u11ab\u1105\u116d \u1107\u1165\u11b7\u110b\u1171"
},
{
@@ -1443,8 +1456,8 @@
"source_location": "L61",
"_origin": "ast",
"id": "concepts_completed_2026_09_04_\ubcf4\uc874\ub41c_\ubbf8\uc644\ub8cc_\ubc94\uc704",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 172,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"norm_label": "\u1107\u1169\u110c\u1169\u11ab\u1103\u116c\u11ab \u1106\u1175\u110b\u116a\u11ab\u1105\u116d \u1107\u1165\u11b7\u110b\u1171"
},
{
@@ -1905,8 +1918,8 @@
"source_location": "L1",
"_origin": "ast",
"id": "concepts_db_schema_overview",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 175,
"community_name": "DB \uc2a4\ud0a4\ub9c8 \uac1c\uc694",
"norm_label": "overview.md"
},
{
@@ -1916,8 +1929,8 @@
"source_location": "L9",
"_origin": "ast",
"id": "concepts_db_schema_overview_db_\uc2a4\ud0a4\ub9c8_\uac1c\uc694",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 175,
"community_name": "DB \uc2a4\ud0a4\ub9c8 \uac1c\uc694",
"norm_label": "db \u1109\u1173\u110f\u1175\u1106\u1161 \u1100\u1162\u110b\u116d"
},
{
@@ -1927,8 +1940,8 @@
"source_location": "L22",
"_origin": "ast",
"id": "concepts_db_schema_overview_\ud14c\uc774\ube14_\uadf8\ub8f9_9\uac1c",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 175,
"community_name": "DB \uc2a4\ud0a4\ub9c8 \uac1c\uc694",
"norm_label": "\u1110\u1166\u110b\u1175\u1107\u1173\u11af \u1100\u1173\u1105\u116e\u11b8 (9\u1100\u1162)"
},
{
@@ -1938,8 +1951,8 @@
"source_location": "L34",
"_origin": "ast",
"id": "concepts_db_schema_overview_\ud30c\uc77c_\uacbd\ub85c_\ucd94\uc801_\uceec\ub7fc_db\uc5d0_\uacbd\ub85c\ub9cc_\uae30\ub85d_\uc2e4_\ud30c\uc77c\uc740_\ud30c\uc77c\uc2dc\uc2a4\ud15c",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 175,
"community_name": "DB \uc2a4\ud0a4\ub9c8 \uac1c\uc694",
"norm_label": "\u1111\u1161\u110b\u1175\u11af \u1100\u1167\u11bc\u1105\u1169 \u110e\u116e\u110c\u1165\u11a8 \u110f\u1165\u11af\u1105\u1165\u11b7 (db\u110b\u1166 \u1100\u1167\u11bc\u1105\u1169\u1106\u1161\u11ab \u1100\u1175\u1105\u1169\u11a8, \u1109\u1175\u11af \u1111\u1161\u110b\u1175\u11af\u110b\u1173\u11ab \u1111\u1161\u110b\u1175\u11af\u1109\u1175\u1109\u1173\u1110\u1166\u11b7)"
},
{
@@ -1949,8 +1962,8 @@
"source_location": "L51",
"_origin": "ast",
"id": "concepts_db_schema_overview_\uc124\uacc4_\uc6d0\uce59",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 175,
"community_name": "DB \uc2a4\ud0a4\ub9c8 \uac1c\uc694",
"norm_label": "\u1109\u1165\u11af\u1100\u1168 \u110b\u116f\u11ab\u110e\u1175\u11a8"
},
{
@@ -1960,8 +1973,8 @@
"source_location": "L56",
"_origin": "ast",
"id": "concepts_db_schema_overview_\ud14c\uc774\ube14_\uad00\uacc4_\ud575\uc2ec_\ud750\ub984",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 175,
"community_name": "DB \uc2a4\ud0a4\ub9c8 \uac1c\uc694",
"norm_label": "\u1110\u1166\u110b\u1175\u1107\u1173\u11af \u1100\u116a\u11ab\u1100\u1168 (\u1112\u1162\u11a8\u1109\u1175\u11b7 \u1112\u1173\u1105\u1173\u11b7)"
},
{
@@ -2521,8 +2534,8 @@
"source_location": "L1",
"_origin": "ast",
"id": "concepts_design_data_lifecycle",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 173,
"community_name": "multi_environment_safety.md",
"norm_label": "design_data_lifecycle.md"
},
{
@@ -2532,8 +2545,8 @@
"source_location": "L9",
"_origin": "ast",
"id": "concepts_design_data_lifecycle_\uc124\uacc4_\ub370\uc774\ud130_\uc0dd\uba85\uc8fc\uae30",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 186,
"community_name": "\uc124\uacc4 \ub370\uc774\ud130 \uc0dd\uba85\uc8fc\uae30",
"norm_label": "\u1109\u1165\u11af\u1100\u1168 \u1103\u1166\u110b\u1175\u1110\u1165 \u1109\u1162\u11bc\u1106\u1167\u11bc\u110c\u116e\u1100\u1175"
},
{
@@ -2543,8 +2556,8 @@
"source_location": "L11",
"_origin": "ast",
"id": "concepts_design_data_lifecycle_\uc815\ubcf8_\uc138_\ubc8c",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 186,
"community_name": "\uc124\uacc4 \ub370\uc774\ud130 \uc0dd\uba85\uc8fc\uae30",
"norm_label": "\u110c\u1165\u11bc\u1107\u1169\u11ab \u1109\u1166 \u1107\u1165\u11af"
},
{
@@ -2554,8 +2567,8 @@
"source_location": "L21",
"_origin": "ast",
"id": "concepts_design_data_lifecycle_\uacc4\ud68d\ub178\uc120_\uaddc\uce59",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 186,
"community_name": "\uc124\uacc4 \ub370\uc774\ud130 \uc0dd\uba85\uc8fc\uae30",
"norm_label": "\u1100\u1168\u1112\u116c\u11a8\u1102\u1169\u1109\u1165\u11ab \u1100\u1172\u110e\u1175\u11a8"
},
{
@@ -2565,8 +2578,8 @@
"source_location": "L28",
"_origin": "ast",
"id": "concepts_design_data_lifecycle_\uacc4\uc0b0_\uad6c\ud604_\uc6d0\uce59",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 186,
"community_name": "\uc124\uacc4 \ub370\uc774\ud130 \uc0dd\uba85\uc8fc\uae30",
"norm_label": "\u1100\u1168\u1109\u1161\u11ab \u1100\u116e\u1112\u1167\u11ab \u110b\u116f\u11ab\u110e\u1175\u11a8"
},
{
@@ -2576,8 +2589,8 @@
"source_location": "L1",
"_origin": "ast",
"id": "concepts_drainage_watershed",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "drainage_watershed.md"
},
{
@@ -2587,8 +2600,8 @@
"source_location": "L7",
"_origin": "ast",
"id": "concepts_drainage_watershed_\ubc30\uc218\uc720\uc5ed_\ud574\uc11d_\ubc0f_\uc138\ubd80\uc124\uacc4_drainage_watershed",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "\u1107\u1162\u1109\u116e\u110b\u1172\u110b\u1167\u11a8 \u1112\u1162\u1109\u1165\u11a8 \u1106\u1175\u11be \u1109\u1166\u1107\u116e\u1109\u1165\u11af\u1100\u1168 (drainage watershed)"
},
{
@@ -2598,8 +2611,8 @@
"source_location": "L13",
"_origin": "ast",
"id": "concepts_drainage_watershed_1_b04_vs_b05_\uc5ed\ud560_\ubd84\ub2f4_\ubc0f_\uc77c\uc6d0\ud654",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "1. b04 vs b05 \u110b\u1167\u11a8\u1112\u1161\u11af \u1107\u116e\u11ab\u1103\u1161\u11b7 \u1106\u1175\u11be \u110b\u1175\u11af\u110b\u116f\u11ab\u1112\u116a"
},
{
@@ -2609,8 +2622,8 @@
"source_location": "L22",
"_origin": "ast",
"id": "concepts_drainage_watershed_2_\uacf5\uc6a9_\ubc30\uc218_\uc5d4\uc9c4_\ubc0f_wamis_\uac15\uc6b0\ub7c9_\uc5f0\ub3d9_phase_1_2_2026_08_13_\uad00\uce21\uc18c_\uc804\ud658",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "2. \u1100\u1169\u11bc\u110b\u116d\u11bc \u1107\u1162\u1109\u116e \u110b\u1166\u11ab\u110c\u1175\u11ab \u1106\u1175\u11be wamis \u1100\u1161\u11bc\u110b\u116e\u1105\u1163\u11bc \u110b\u1167\u11ab\u1103\u1169\u11bc (phase 1~2, 2026-08-13 \u1100\u116a\u11ab\u110e\u1173\u11a8\u1109\u1169 \u110c\u1165\u11ab\u1112\u116a\u11ab)"
},
{
@@ -2620,8 +2633,8 @@
"source_location": "L30",
"_origin": "ast",
"id": "concepts_drainage_watershed_3_\uad6c\uc870\ubb3c_3\ub2e8_\uc635\uc158_\uccb4\uacc4_ui_phase_3_5",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "3. \u1100\u116e\u110c\u1169\u1106\u116e\u11af 3\u1103\u1161\u11ab \u110b\u1169\u11b8\u1109\u1167\u11ab \u110e\u1166\u1100\u1168 & ui (phase 3~5)"
},
{
@@ -2631,8 +2644,8 @@
"source_location": "L36",
"_origin": "ast",
"id": "concepts_drainage_watershed_4_\ud574\uc11d_\uc54c\uace0\ub9ac\uc998_\ub4f1\uace0\uc120_\ud558\uac15_contour_descent",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "4. \u1112\u1162\u1109\u1165\u11a8 \u110b\u1161\u11af\u1100\u1169\u1105\u1175\u110c\u1173\u11b7 \u2014 \u1103\u1173\u11bc\u1100\u1169\u1109\u1165\u11ab \u1112\u1161\u1100\u1161\u11bc (contour descent)"
},
{
@@ -2642,8 +2655,8 @@
"source_location": "L47",
"_origin": "ast",
"id": "concepts_drainage_watershed_5_\uc801\uc0c9_\uccad\uc0c9_\ud310\uc815_\ubc0f_\uc720\uc5ed_\ud655\uc7a5",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "5. \u110c\u1165\u11a8\u1109\u1162\u11a8/\u110e\u1165\u11bc\u1109\u1162\u11a8 \u1111\u1161\u11ab\u110c\u1165\u11bc \u1106\u1175\u11be \u110b\u1172\u110b\u1167\u11a8 \u1112\u116a\u11a8\u110c\u1161\u11bc"
},
{
@@ -2653,8 +2666,8 @@
"source_location": "L56",
"_origin": "ast",
"id": "concepts_drainage_watershed_6_\ud3c9\uade0_\ud750\ub984_\ud654\uc0b4\ud45c_flow_arrows_\ubc0f_\ud750\ub984\uac15\ub3c4_\ub7a8\ud504",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "6. \u1111\u1167\u11bc\u1100\u1172\u11ab \u1112\u1173\u1105\u1173\u11b7 \u1112\u116a\u1109\u1161\u11af\u1111\u116d (flow arrows) \u1106\u1175\u11be \u1112\u1173\u1105\u1173\u11b7\u1100\u1161\u11bc\u1103\u1169 \u1105\u1162\u11b7\u1111\u1173"
},
{
@@ -2664,8 +2677,8 @@
"source_location": "L60",
"_origin": "ast",
"id": "concepts_drainage_watershed_7_\uc601\uad6c\uc800\uc7a5\uc18c_\uc0b0\ucd9c\ubb3c_\uad6c\uc870",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "7. \u110b\u1167\u11bc\u1100\u116e\u110c\u1165\u110c\u1161\u11bc\u1109\u1169 \u1109\u1161\u11ab\u110e\u116e\u11af\u1106\u116e\u11af \u1100\u116e\u110c\u1169"
},
{
@@ -2675,8 +2688,8 @@
"source_location": "L75",
"_origin": "ast",
"id": "concepts_drainage_watershed_8_b08_\uc218\ub9ac\uc9d1\uc218\uba74\uc801\uc720\uc5ed\ub3c4",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "8. b08 \u1109\u116e\u1105\u1175\u110c\u1175\u11b8\u1109\u116e\u1106\u1167\u11ab\u110c\u1165\u11a8\u110b\u1172\u110b\u1167\u11a8\u1103\u1169"
},
{
@@ -2686,8 +2699,8 @@
"source_location": "L1",
"_origin": "ast",
"id": "concepts_las_free_sheet_surface",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "las_free_sheet_surface.md"
},
{
@@ -2697,8 +2710,8 @@
"source_location": "L9",
"_origin": "ast",
"id": "concepts_las_free_sheet_surface_las_\uc5c6\ub294_\ub3c4\uc5fd\ub4f1\uace0\uc120_\uc11c\ud53c\uc2a4",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "las \u110b\u1165\u11b9\u1102\u1173\u11ab \u1103\u1169\u110b\u1167\u11b8\u1103\u1173\u11bc\u1100\u1169\u1109\u1165\u11ab \u1109\u1165\u1111\u1175\u1109\u1173"
},
{
@@ -2708,8 +2721,8 @@
"source_location": "L11",
"_origin": "ast",
"id": "concepts_las_free_sheet_surface_\uacc4\uc57d\uacfc_\ud655\uc815\uac12",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "\u1100\u1168\u110b\u1163\u11a8\u1100\u116a \u1112\u116a\u11a8\u110c\u1165\u11bc\u1100\u1161\u11b9"
},
{
@@ -2719,8 +2732,8 @@
"source_location": "L22",
"_origin": "ast",
"id": "concepts_las_free_sheet_surface_\ud750\ub984",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "\u1112\u1173\u1105\u1173\u11b7"
},
{
@@ -2730,8 +2743,8 @@
"source_location": "L33",
"_origin": "ast",
"id": "concepts_las_free_sheet_surface_e2e_\uacb0\uacfc",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "e2e \u1100\u1167\u11af\u1100\u116a"
},
{
@@ -2741,8 +2754,8 @@
"source_location": "L40",
"_origin": "ast",
"id": "concepts_las_free_sheet_surface_\ubbf8\uacb0",
"community": 0,
"community_name": "UI Templates \u2014 Localization & Components",
"community": 7,
"community_name": "2026-08-29 \uc644\ub8cc \ubc18\uc601",
"norm_label": "\u1106\u1175\u1100\u1167\u11af"
},
{
@@ -2906,8 +2919,8 @@
"source_location": "L1",
"_origin": "ast",
"id": "concepts_multi_environment_safety",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 173,
"community_name": "multi_environment_safety.md",
"norm_label": "multi_environment_safety.md"
},
{
@@ -2917,8 +2930,8 @@
"source_location": "L9",
"_origin": "ast",
"id": "concepts_multi_environment_safety_\ub2e4\uc911_\ud658\uacbd_\uc800\uc7a5\uc18c_\uacf5\uc6a9_db_\uc548\uc804",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 180,
"community_name": "\ub2e4\uc911 \ud658\uacbd \uc800\uc7a5\uc18c\u00b7\uacf5\uc6a9 DB \uc548\uc804",
"norm_label": "\u1103\u1161\u110c\u116e\u11bc \u1112\u116a\u11ab\u1100\u1167\u11bc \u110c\u1165\u110c\u1161\u11bc\u1109\u1169\u00b7\u1100\u1169\u11bc\u110b\u116d\u11bc db \u110b\u1161\u11ab\u110c\u1165\u11ab"
},
{
@@ -2928,8 +2941,8 @@
"source_location": "L11",
"_origin": "ast",
"id": "concepts_multi_environment_safety_\ud655\uc778\ub41c_\uc704\ud5d8",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 180,
"community_name": "\ub2e4\uc911 \ud658\uacbd \uc800\uc7a5\uc18c\u00b7\uacf5\uc6a9 DB \uc548\uc804",
"norm_label": "\u1112\u116a\u11a8\u110b\u1175\u11ab\u1103\u116c\u11ab \u110b\u1171\u1112\u1165\u11b7"
},
{
@@ -2939,8 +2952,8 @@
"source_location": "L18",
"_origin": "ast",
"id": "concepts_multi_environment_safety_\uc6b4\uc601_\uaddc\uce59",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 180,
"community_name": "\ub2e4\uc911 \ud658\uacbd \uc800\uc7a5\uc18c\u00b7\uacf5\uc6a9 DB \uc548\uc804",
"norm_label": "\u110b\u116e\u11ab\u110b\u1167\u11bc \u1100\u1172\u110e\u1175\u11a8"
},
{
@@ -2950,30 +2963,41 @@
"source_location": "L29",
"_origin": "ast",
"id": "concepts_multi_environment_safety_\uc5ec\uc12f_\uc6cc\ud06c\ud2b8\ub9ac_\uc6b4\uc601_\ud655\uc815\ud310",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 180,
"community_name": "\ub2e4\uc911 \ud658\uacbd \uc800\uc7a5\uc18c\u00b7\uacf5\uc6a9 DB \uc548\uc804",
"norm_label": "\u110b\u1167\u1109\u1165\u11ba \u110b\u116f\u110f\u1173\u1110\u1173\u1105\u1175 \u110b\u116e\u11ab\u110b\u1167\u11bc \u1112\u116a\u11a8\u110c\u1165\u11bc\u1111\u1161\u11ab"
},
{
"label": "Git \ud55c \ubc14\ud034",
"file_type": "document",
"source_file": "concepts/multi_environment_safety.md",
"source_location": "L43",
"_origin": "ast",
"id": "concepts_multi_environment_safety_git_\ud55c_\ubc14\ud034",
"community": 180,
"community_name": "\ub2e4\uc911 \ud658\uacbd \uc800\uc7a5\uc18c\u00b7\uacf5\uc6a9 DB \uc548\uc804",
"norm_label": "git \u1112\u1161\u11ab \u1107\u1161\u110f\u1171"
},
{
"label": "\uc644\ub8cc \ud310\uc815",
"file_type": "document",
"source_file": "concepts/multi_environment_safety.md",
"source_location": "L43",
"source_location": "L54",
"_origin": "ast",
"id": "concepts_multi_environment_safety_\uc644\ub8cc_\ud310\uc815",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 180,
"community_name": "\ub2e4\uc911 \ud658\uacbd \uc800\uc7a5\uc18c\u00b7\uacf5\uc6a9 DB \uc548\uc804",
"norm_label": "\u110b\u116a\u11ab\u1105\u116d \u1111\u1161\u11ab\u110c\u1165\u11bc"
},
{
"label": "\uc7ac\uacc4\uc0b0 \uc601\ud5a5",
"file_type": "document",
"source_file": "concepts/multi_environment_safety.md",
"source_location": "L49",
"source_location": "L60",
"_origin": "ast",
"id": "concepts_multi_environment_safety_\uc7ac\uacc4\uc0b0_\uc601\ud5a5",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 180,
"community_name": "\ub2e4\uc911 \ud658\uacbd \uc800\uc7a5\uc18c\u00b7\uacf5\uc6a9 DB \uc548\uc804",
"norm_label": "\u110c\u1162\u1100\u1168\u1109\u1161\u11ab \u110b\u1167\u11bc\u1112\u1163\u11bc"
},
{
@@ -3192,8 +3216,8 @@
"source_location": "L1",
"_origin": "ast",
"id": "concepts_storage_paths",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 175,
"community_name": "DB \uc2a4\ud0a4\ub9c8 \uac1c\uc694",
"norm_label": "storage_paths.md"
},
{
@@ -3203,8 +3227,8 @@
"source_location": "L9",
"_origin": "ast",
"id": "concepts_storage_paths_\uc800\uc7a5_\uacbd\ub85c_\uaddc\uce59_workflow_based_folder_structure",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 181,
"community_name": "\uc800\uc7a5 \uacbd\ub85c \uaddc\uce59 (Workflow-based Folder Structure)",
"norm_label": "\u110c\u1165\u110c\u1161\u11bc \u1100\u1167\u11bc\u1105\u1169 \u1100\u1172\u110e\u1175\u11a8 (workflow-based folder structure)"
},
{
@@ -3214,8 +3238,8 @@
"source_location": "L11",
"_origin": "ast",
"id": "concepts_storage_paths_\uacbd\ub85c_\ud328\ud134",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 181,
"community_name": "\uc800\uc7a5 \uacbd\ub85c \uaddc\uce59 (Workflow-based Folder Structure)",
"norm_label": "\u1100\u1167\u11bc\u1105\u1169 \u1111\u1162\u1110\u1165\u11ab"
},
{
@@ -3225,8 +3249,8 @@
"source_location": "L29",
"_origin": "ast",
"id": "concepts_storage_paths_\uc6d0\uce59_backend_md_3\uc808",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 181,
"community_name": "\uc800\uc7a5 \uacbd\ub85c \uaddc\uce59 (Workflow-based Folder Structure)",
"norm_label": "\u110b\u116f\u11ab\u110e\u1175\u11a8 (backend.md 3\u110c\u1165\u11af)"
},
{
@@ -3236,8 +3260,8 @@
"source_location": "L36",
"_origin": "ast",
"id": "concepts_storage_paths_db_\uceec\ub7fc_\uc2e4\uc81c_\uacbd\ub85c_\ub9e4\ud551",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 181,
"community_name": "\uc800\uc7a5 \uacbd\ub85c \uaddc\uce59 (Workflow-based Folder Structure)",
"norm_label": "db \u110f\u1165\u11af\u1105\u1165\u11b7 \u2194 \u1109\u1175\u11af\u110c\u1166 \u1100\u1167\u11bc\u1105\u1169 \u1106\u1162\u1111\u1175\u11bc"
},
{
@@ -3247,8 +3271,8 @@
"source_location": "L39",
"_origin": "ast",
"id": "concepts_storage_paths_\ud30c\uc77c\uba85_\uaddc\uce59_structure_md_1\uc808",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 181,
"community_name": "\uc800\uc7a5 \uacbd\ub85c \uaddc\uce59 (Workflow-based Folder Structure)",
"norm_label": "\u1111\u1161\u110b\u1175\u11af\u1106\u1167\u11bc \u1100\u1172\u110e\u1175\u11a8 (structure.md 1\u110c\u1165\u11af)"
},
{
@@ -3258,8 +3282,8 @@
"source_location": "L44",
"_origin": "ast",
"id": "concepts_storage_paths_\ucf54\ub4dc_\uac10\uc0ac_\uc8fc\uc758\uc0ac\ud56d",
"community": 1,
"community_name": "\uc778\uc99d / RBAC",
"community": 181,
"community_name": "\uc800\uc7a5 \uacbd\ub85c \uaddc\uce59 (Workflow-based Folder Structure)",
"norm_label": "\u110f\u1169\u1103\u1173 \u1100\u1161\u11b7\u1109\u1161 \u110c\u116e\u110b\u1174\u1109\u1161\u1112\u1161\u11bc"
},
{
@@ -3467,8 +3491,8 @@
"source_location": "L1",
"_origin": "ast",
"id": "concepts_workflow_state",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 173,
"community_name": "multi_environment_safety.md",
"norm_label": "workflow_state.md"
},
{
@@ -3478,8 +3502,8 @@
"source_location": "L9",
"_origin": "ast",
"id": "concepts_workflow_state_workflow_\uc0c1\ud0dc_\uad00\ub9ac",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 174,
"community_name": "Workflow \uc0c1\ud0dc \uad00\ub9ac",
"norm_label": "workflow \u1109\u1161\u11bc\u1110\u1162 \u1100\u116a\u11ab\u1105\u1175"
},
{
@@ -3489,8 +3513,8 @@
"source_location": "L13",
"_origin": "ast",
"id": "concepts_workflow_state_ssot_project_workflow_stages_\ud14c\uc774\ube14_\uc2e4_db_\ud655\uc778",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 174,
"community_name": "Workflow \uc0c1\ud0dc \uad00\ub9ac",
"norm_label": "ssot: `project_workflow_stages` \u1110\u1166\u110b\u1175\u1107\u1173\u11af (\u1109\u1175\u11af db \u1112\u116a\u11a8\u110b\u1175\u11ab)"
},
{
@@ -3500,8 +3524,8 @@
"source_location": "L27",
"_origin": "ast",
"id": "concepts_workflow_state_r1_\uc6cc\ud06c\ud50c\ub85c\uc6b0_\ub2e8\uacc4_\uc7ac\ud3b8_2026_08_08_\ubc18\uc601",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 174,
"community_name": "Workflow \uc0c1\ud0dc \uad00\ub9ac",
"norm_label": "r1 \u110b\u116f\u110f\u1173\u1111\u1173\u11af\u1105\u1169\u110b\u116e \u1103\u1161\u11ab\u1100\u1168 \u110c\u1162\u1111\u1167\u11ab (2026-08-08 \u1107\u1161\u11ab\u110b\u1167\u11bc)"
},
{
@@ -3511,8 +3535,8 @@
"source_location": "L35",
"_origin": "ast",
"id": "concepts_workflow_state_\ubc31\uadf8\ub77c\uc6b4\ub4dc_\uc790\ub3d9_\uacc4\uc0b0_\uccb4\uc778_\ubc0f_\uc0ac\uc6a9\uc790_\uc124\uc815_\uc774\uc6d4",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 174,
"community_name": "Workflow \uc0c1\ud0dc \uad00\ub9ac",
"norm_label": "\u1107\u1162\u11a8\u1100\u1173\u1105\u1161\u110b\u116e\u11ab\u1103\u1173 \u110c\u1161\u1103\u1169\u11bc \u1100\u1168\u1109\u1161\u11ab \u110e\u1166\u110b\u1175\u11ab \u1106\u1175\u11be \u1109\u1161\u110b\u116d\u11bc\u110c\u1161 \u1109\u1165\u11af\u110c\u1165\u11bc \u110b\u1175\u110b\u116f\u11af"
},
{
@@ -3522,8 +3546,8 @@
"source_location": "L40",
"_origin": "ast",
"id": "concepts_workflow_state_\uacf5\ud1b5_\uc720\ud2f8_common_util_common_util_workflow_state_py",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 174,
"community_name": "Workflow \uc0c1\ud0dc \uad00\ub9ac",
"norm_label": "\u1100\u1169\u11bc\u1110\u1169\u11bc \u110b\u1172\u1110\u1175\u11af `common_util/common_util_workflow_state.py`"
},
{
@@ -3533,8 +3557,8 @@
"source_location": "L51",
"_origin": "ast",
"id": "concepts_workflow_state_\ubb34\ud6a8\ud654\uc758_\uc2e4\uc81c_\ubc94\uc704",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 174,
"community_name": "Workflow \uc0c1\ud0dc \uad00\ub9ac",
"norm_label": "\u1106\u116e\u1112\u116d\u1112\u116a\u110b\u1174 \u1109\u1175\u11af\u110c\u1166 \u1107\u1165\u11b7\u110b\u1171"
},
{
@@ -3544,8 +3568,8 @@
"source_location": "L60",
"_origin": "ast",
"id": "concepts_workflow_state_\uc870\ud68c_api",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 174,
"community_name": "Workflow \uc0c1\ud0dc \uad00\ub9ac",
"norm_label": "\u110c\u1169\u1112\u116c api"
},
{
@@ -3555,8 +3579,8 @@
"source_location": "L63",
"_origin": "ast",
"id": "concepts_workflow_state_\ud504\ub860\ud2b8\uc5d4\ub4dc_\uac8c\uc774\ud305_\ubc0f_\uc2a4\ud15d\ubc14_\uc5f0\ub3d9",
"community": 3,
"community_name": "2026-09-04 \uc644\ub8cc \ud56d\ubaa9",
"community": 174,
"community_name": "Workflow \uc0c1\ud0dc \uad00\ub9ac",
"norm_label": "\u1111\u1173\u1105\u1169\u11ab\u1110\u1173\u110b\u1166\u11ab\u1103\u1173 \u1100\u1166\u110b\u1175\u1110\u1175\u11bc \u1106\u1175\u11be \u1109\u1173\u1110\u1166\u11b8\u1107\u1161 \u110b\u1167\u11ab\u1103\u1169\u11bc"
},
{
@@ -3739,7 +3763,7 @@
"label": "\ud604\uc7ac \uc8fc\uc758\uc0ac\ud56d",
"file_type": "document",
"source_file": "index.md",
"source_location": "L75",
"source_location": "L76",
"_origin": "ast",
"id": "index_\ud604\uc7ac_\uc8fc\uc758\uc0ac\ud56d",
"community": 33,
@@ -13695,6 +13719,72 @@
"target": "concepts_b07_external_webcad_demos_\uc2e4\ud589\uacfc_\uc885\ub8cc",
"confidence_score": 1.0
},
{
"relation": "contains",
"confidence": "EXTRACTED",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L9",
"weight": 1.0,
"_origin": "ast",
"source": "concepts_browser_verification_operations",
"target": "concepts_browser_verification_operations_\ud654\uba74_\uac80\uc99d_\ube0c\ub77c\uc6b0\uc800_\uc6b4\uc6a9",
"confidence_score": 1.0
},
{
"relation": "references",
"confidence": "EXTRACTED",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L4",
"weight": 1.0,
"_origin": "ast",
"source": "concepts_browser_verification_operations",
"target": "concepts_design_data_lifecycle",
"confidence_score": 1.0
},
{
"relation": "references",
"confidence": "EXTRACTED",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L4",
"weight": 1.0,
"_origin": "ast",
"source": "concepts_browser_verification_operations",
"target": "concepts_multi_environment_safety",
"confidence_score": 1.0
},
{
"relation": "contains",
"confidence": "EXTRACTED",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L17",
"weight": 1.0,
"_origin": "ast",
"source": "concepts_browser_verification_operations_\ud654\uba74_\uac80\uc99d_\ube0c\ub77c\uc6b0\uc800_\uc6b4\uc6a9",
"target": "concepts_browser_verification_operations_\uacf5\uc6a9_\ube0c\ub77c\uc6b0\uc800_\uc2dc\ub3d9\uacfc_\uc870\uc791",
"confidence_score": 1.0
},
{
"relation": "contains",
"confidence": "EXTRACTED",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L11",
"weight": 1.0,
"_origin": "ast",
"source": "concepts_browser_verification_operations_\ud654\uba74_\uac80\uc99d_\ube0c\ub77c\uc6b0\uc800_\uc6b4\uc6a9",
"target": "concepts_browser_verification_operations_\uae30\ubcf8_\uc120\ud0dd",
"confidence_score": 1.0
},
{
"relation": "contains",
"confidence": "EXTRACTED",
"source_file": "concepts/browser_verification_operations.md",
"source_location": "L29",
"weight": 1.0,
"_origin": "ast",
"source": "concepts_browser_verification_operations_\ud654\uba74_\uac80\uc99d_\ube0c\ub77c\uc6b0\uc800_\uc6b4\uc6a9",
"target": "concepts_browser_verification_operations_\ubcc0\uacbd_\uc885\ub958\ubcc4_\ubc18\uc601",
"confidence_score": 1.0
},
{
"relation": "contains",
"confidence": "EXTRACTED",
@@ -15752,6 +15842,17 @@
"target": "concepts_workflow_state",
"confidence_score": 1.0
},
{
"relation": "contains",
"confidence": "EXTRACTED",
"source_file": "concepts/multi_environment_safety.md",
"source_location": "L43",
"weight": 1.0,
"_origin": "ast",
"source": "concepts_multi_environment_safety_\ub2e4\uc911_\ud658\uacbd_\uc800\uc7a5\uc18c_\uacf5\uc6a9_db_\uc548\uc804",
"target": "concepts_multi_environment_safety_git_\ud55c_\ubc14\ud034",
"confidence_score": 1.0
},
{
"relation": "contains",
"confidence": "EXTRACTED",
@@ -15767,7 +15868,7 @@
"relation": "contains",
"confidence": "EXTRACTED",
"source_file": "concepts/multi_environment_safety.md",
"source_location": "L43",
"source_location": "L54",
"weight": 1.0,
"_origin": "ast",
"source": "concepts_multi_environment_safety_\ub2e4\uc911_\ud658\uacbd_\uc800\uc7a5\uc18c_\uacf5\uc6a9_db_\uc548\uc804",
@@ -15789,7 +15890,7 @@
"relation": "contains",
"confidence": "EXTRACTED",
"source_file": "concepts/multi_environment_safety.md",
"source_location": "L49",
"source_location": "L60",
"weight": 1.0,
"_origin": "ast",
"source": "concepts_multi_environment_safety_\ub2e4\uc911_\ud658\uacbd_\uc800\uc7a5\uc18c_\uacf5\uc6a9_db_\uc548\uc804",
@@ -16493,7 +16594,7 @@
"relation": "contains",
"confidence": "EXTRACTED",
"source_file": "index.md",
"source_location": "L75",
"source_location": "L76",
"weight": 1.0,
"_origin": "ast",
"source": "index_wiki_index",
@@ -22605,28 +22706,6 @@
"target": "pages_b07_designdetail_b07_standard_drawings_2026_09_b07_\uad6c\uc870\ubb3c_\ud45c\uc900\ub3c4_\uc870\uc0ac_\ud569\uc758\uc640_\ud604\uc7ac_\ud1b5\ub85c",
"confidence_score": 1.0
},
{
"relation": "references",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"weight": 1.0,
"source_file": "pages/B07_DesignDetail/B07_standard_drawings_2026_09.md",
"source_location": "wikilink",
"_origin": "curated",
"source": "pages_b07_designdetail_b07_standard_drawings_2026_09_b07_\uad6c\uc870\ubb3c_\ud45c\uc900\ub3c4_\uc870\uc0ac_\ud569\uc758\uc640_\ud604\uc7ac_\ud1b5\ub85c",
"target": "pages_b08_quantity_b08_overview_2026_09_b08_quantity_2026_09_\uc218\ub7c9\uc0b0\ucd9c"
},
{
"relation": "references",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"weight": 1.0,
"source_file": "pages/B07_DesignDetail/B07_standard_drawings_2026_09.md",
"source_location": "wikilink",
"_origin": "curated",
"source": "pages_b07_designdetail_b07_standard_drawings_2026_09_b07_\uad6c\uc870\ubb3c_\ud45c\uc900\ub3c4_\uc870\uc0ac_\ud569\uc758\uc640_\ud604\uc7ac_\ud1b5\ub85c",
"target": "pages_b09_estimation_b09_overview_2026_09_b09_estimation_2026_09_\uc6d0\uac00\uacc4\uc0b0"
},
{
"relation": "contains",
"confidence": "EXTRACTED",
@@ -22682,6 +22761,28 @@
"target": "pages_b07_designdetail_b07_standard_drawings_2026_09_\uc870\uc0ac\ub85c_\ud655\uc778\ub41c_\uc124\uacc4_\uc6d0\uce59",
"confidence_score": 1.0
},
{
"relation": "references",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"weight": 1.0,
"source_file": "pages/B07_DesignDetail/B07_standard_drawings_2026_09.md",
"source_location": "wikilink",
"_origin": "curated",
"source": "pages_b07_designdetail_b07_standard_drawings_2026_09_b07_\uad6c\uc870\ubb3c_\ud45c\uc900\ub3c4_\uc870\uc0ac_\ud569\uc758\uc640_\ud604\uc7ac_\ud1b5\ub85c",
"target": "pages_b08_quantity_b08_overview_2026_09_b08_quantity_2026_09_\uc218\ub7c9\uc0b0\ucd9c"
},
{
"relation": "references",
"confidence": "EXTRACTED",
"confidence_score": 1.0,
"weight": 1.0,
"source_file": "pages/B07_DesignDetail/B07_standard_drawings_2026_09.md",
"source_location": "wikilink",
"_origin": "curated",
"source": "pages_b07_designdetail_b07_standard_drawings_2026_09_b07_\uad6c\uc870\ubb3c_\ud45c\uc900\ub3c4_\uc870\uc0ac_\ud569\uc758\uc640_\ud604\uc7ac_\ud1b5\ub85c",
"target": "pages_b09_estimation_b09_overview_2026_09_b09_estimation_2026_09_\uc6d0\uac00\uacc4\uc0b0"
},
{
"relation": "contains",
"confidence": "EXTRACTED",
@@ -23927,48 +24028,6 @@
}
],
"hyperedges": [
{
"id": "workflow_b03_b08_integration",
"label": "B03-B08 Workflow Data Flow",
"nodes": [
"b03_fileinput_route_snapshot_crs",
"b04_preprocess_drainage_compass_crs",
"b05_profile_frontend",
"b06_section_cross_design_ui_2026_09",
"b08_designdetail_frontend"
],
"relation": "participate_in",
"confidence": "INFERRED",
"confidence_score": 0.9,
"source_file": "index.md"
},
{
"id": "mass_haul_shared_logic",
"label": "Shared Mass Haul Calculation and UI",
"nodes": [
"b05_profile_masshaul_structure_2026_09",
"b06_section_masshaul_culvert_2026_09",
"b08_designdetail_cad_delivery_2026_09"
],
"relation": "participate_in",
"confidence": "EXTRACTED",
"confidence_score": 0.85,
"source_file": "pages/B05_Profile/B05_masshaul_structure_2026_09.md"
},
{
"id": "cad_delivery_system",
"label": "CAD Delivery and Usability Framework",
"nodes": [
"b08_designdetail_cad_interaction",
"b08_designdetail_cad_title_block",
"b08_designdetail_cad_usability_2026_09_01",
"b08_designdetail_cad_delivery_2026_09"
],
"relation": "form",
"confidence": "EXTRACTED",
"confidence_score": 0.95,
"source_file": "pages/B08_DesignDetail/B08_cad_delivery_2026_09.md"
},
{
"id": "las_free_workflow_chain",
"label": "LAS-Free Analysis Workflow",
@@ -24049,5 +24108,5 @@
"confidence_score": 1.0
}
],
"built_at_commit": "e49e4d8526c0c6fc08d488546cda8445dbea1a31"
"built_at_commit": "d6e45bb431c88702c49e6cb7ea6e0414346064d7"
}
+9 -4
View File
@@ -175,8 +175,8 @@
"semantic_hash": ""
},
"index.md": {
"mtime": 1789132135.2547836,
"ast_hash": "07f8a8513e83d93de0bf992e1fc4e501",
"mtime": 1789177295.6797397,
"ast_hash": "b918b75614e641023689764d2dab781b",
"semantic_hash": ""
},
"ingest/index.md": {
@@ -1025,8 +1025,8 @@
"semantic_hash": ""
},
"concepts/multi_environment_safety.md": {
"mtime": 1789132131.6764865,
"ast_hash": "1667aa4c657a4f529de42bcaac3fcd44",
"mtime": 1789177284.6051137,
"ast_hash": "bd15bb9032668bee166d16eaf600dc14",
"semantic_hash": ""
},
"concepts/quantity_cost_contract.md": {
@@ -1073,5 +1073,10 @@
"mtime": 1789128567.000224,
"ast_hash": "0b98a093e67d58c91f57ab12971c3da0",
"semantic_hash": ""
},
"concepts/browser_verification_operations.md": {
"mtime": 1789177288.405969,
"ast_hash": "23611cfc9c4dd173829d51130ba779b1",
"semantic_hash": ""
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
---
type: index
status: stable
last_updated: 2026-09-11
last_updated: 2026-09-12
---
# Wiki Index
@@ -65,6 +65,7 @@ last_updated: 2026-09-11
- 표준도·입력 미결: [[standard_drawing_cost_inputs]] — 자동 계산·사용자 입력·기준자료·미확보 경계.
- 2026-09-09 미결 근거: [[standard_quantity_open_2026-09-09]] — 표준도 제원·수량·원가 후보와 확정 금지 경계.
- 다중 환경 안전: [[multi_environment_safety]] — 여섯 워크트리·링크·Git 역할과 공용 DB 사고 방지 규칙.
- 화면 검증 브라우저: [[browser_verification_operations]] — Orca 기본·공용 브라우저 예외 운용과 변경별 갱신 절차.
- 인증·상태: [[auth_rbac]], [[workflow_state]], [[temp_upload]].
- 데이터·경로: [[db_schema/overview]], [[storage_paths]], [[crs_metadata]].
- API·스키마·유틸: [[api_common]], [[schema_common]], [[common_util]].
+3 -1
View File
@@ -2,7 +2,7 @@
type: ingest-register
status: stable
related_pages: ["[[ingest/index]]", "[[completed_2026-09-01]]", "[[completed_2026-09-09]]", "[[design_data_lifecycle]]", "[[multi_environment_safety]]"]
last_updated: 2026-09-11
last_updated: 2026-09-12
---
# 2026-09 검증 인제스트
@@ -16,3 +16,5 @@ last_updated: 2026-09-11
| `docs/raw/verification/2026-09-09g_계획서_0_3_4장_완료근거_이관.md` | `ingested` | [[completed_2026-09-09]], [[B07_DesignDetail/B07_standard_drawings_2026_09]] |
| `docs/raw/verification/2026-09-10_계획서_e_f_g_이관_교차검증.md` | `verification` | [[completed_2026-09-09]], [[design_data_lifecycle]] |
| `docs/raw/verification/2026-09-11_창환경_링크와_깃운영_조사.md` | `ingested` | [[multi_environment_safety]] |
| `docs/raw/verification/2026-09-12_깃_합류점_dev_전환.md` | `ingested` | [[multi_environment_safety]] |
| `docs/raw/verification/2026-09-12_공용_브라우저_운용.md` | `ingested` | [[browser_verification_operations]] |
+7 -1
View File
@@ -1,9 +1,15 @@
---
type: log
status: stable
last_updated: 2026-09-11
last_updated: 2026-09-12
---
## [2026-09-12] ingest+lint+graphify | Git 합류점·화면 검증 브라우저 운용 반영
- `main` 정본·`dev` 합류점·환경 브랜치 6개의 받기→작업→밀기와 사용자 지시형 `-Converge` 절차를 [[multi_environment_safety]]에 반영했다.
- Orca 내장 브라우저 기본, 공용 브라우저 예외 사용, 명령 큐·캐시·재시작·수치 판정 규칙을 [[browser_verification_operations]]에 정리했다.
- 두 검증 원본을 [[ingest/verification_2026_09]]에 등록했다.
- 비로그 위키의 100줄·frontmatter·status·page_id 검사와 신규 링크 검사를 통과했다. Graphify는 1,176노드·1,003관계·196커뮤니티로 갱신했고 새 두 개념의 질의 연결을 확인했다.
## [2026-09-11] ingest+lint | 여섯 워크트리·링크·Git 운영 완료 반영
- 상시계획서 10번 원문 전체를 `docs/raw/plans/2026-09-11_plan_창환경_여섯워크트리_링크_깃운영.md`로 이관했다.
- 사용자 요청에 따라 소스코드 2차 검증과 별도 검증보고서는 생략하고, 기존 운영 실측 원문을 [[multi_environment_safety]]에 반영했다.
+3 -1
View File
@@ -48,15 +48,16 @@ from B05_Profile.B05_Profile_Router import router as b05_route_router
from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router
from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router
from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_router
from B05_Profile.B05_Profile_Router_Terrain import router as b05_route_terrain_router
from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router
from B06_Section.B06_Section_Router import router as b06_section_router
from B06_Section.B06_Section_Router_Stations import router as b06_section_stations_router
from B06_Section.B06_Section_Router_Confirm import (
router as b06_section_confirm_router,
)
from B06_Section.B06_Section_Router_HaulPlan import (
router as b06_section_haul_plan_router,
)
from B06_Section.B06_Section_Router_Stations import router as b06_section_stations_router
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
from B07_DesignDetail.B07_DesignDetail_Router_Standard import router as b07_standard_router
@@ -537,6 +538,7 @@ app.include_router(b05_route_router, dependencies=protected_with_company)
app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company)
app.include_router(b05_corridor_router, dependencies=protected_with_company)
app.include_router(b05_route_replan_router, dependencies=protected_with_company)
app.include_router(b05_route_terrain_router, dependencies=protected_with_company)
app.include_router(b05_structures_router, dependencies=protected_with_company)
app.include_router(b06_section_router, dependencies=protected_with_company)
app.include_router(b06_section_stations_router, dependencies=protected_with_company)
@@ -0,0 +1,45 @@
"""계획노선 곡선 **하한**(반지름·곡선 길이) 해석 시험.
기본값(`legal_plan_radius_min_m`) ** 넘는 하한**(`plan_radius_limit_m`) 다른 값이다
(2026-09-12 사용자 확정). 둘을 값으로 묶으면 작업임도의 하한 0 반지름 0 되어
곡선이 아예 그려지므로, 갈라져 있다는 자체를 시험으로 못박는다.
"""
from B05_Profile.B05_Profile_Engine_Grade import (
legal_plan_radius_min_m,
plan_curve_length_limit_m,
plan_radius_limit_m,
resolve_design_speed,
)
def _default(grade_class: str, terrain: str) -> float:
return legal_plan_radius_min_m(resolve_design_speed(grade_class, None), terrain)
def test_작업임도는_하한이_없다():
"""별표2에 작업임도 곡선반지름 규정이 없어 하한을 0(제한 없음)으로 열어 둔다."""
assert plan_radius_limit_m("work", None, "normal") == 0.0
assert plan_radius_limit_m("work", None, "special") == 0.0
def test_작업임도도_기본_반지름은_그대로다():
"""하한이 0이어도 **곡선을 만들 때 쓰는 기본값**은 살아 있어야 한다."""
assert _default("work", "normal") > 0
def test_간선_산불진화는_법정표가_곧_하한이다():
for grade_class in ("main", "trunk", "fire"):
for terrain in ("normal", "special"):
assert plan_radius_limit_m(grade_class, None, terrain) == _default(grade_class, terrain)
def test_모르는_임도종류는_법정표로_떨어진다():
"""칸이 없는 값이 와도 막지 않고 법정표를 하한으로 쓴다(가장 보수적인 쪽)."""
assert plan_radius_limit_m("알 수 없는 종류", None, "normal") == _default("work", "normal")
def test_곡선길이_하한은_아직_전부_0이다():
"""법령·교본에 평면 곡선 길이 하한 값이 없다 — 자리만 열어 둔 칸이다."""
for grade_class in ("main", "trunk", "fire", "work", "branch", "없는종류"):
assert plan_curve_length_limit_m(grade_class) == 0.0