무슨 일이 있었나 랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운 커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`· `CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴. 잃었던 것 - 공용 — `common_util_provenance.py` · `ui_template_provenance.ts` - B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸 - B09 — 근거 사전 셋 - B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈 - 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등) 어떻게 되살렸나 `611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림. 이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난 옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛 상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 — `9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑). 위키 여덟은 코덱스 몫이라 손대지 않음. 자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 · `B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의 `attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped** (되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* 2D 지도 오버레이 도형 (B04 지도 · B05 배수유역도 공용)
|
|
*
|
|
* 유역 채움·번호 배지·분수령 파선·상류 세류망처럼 **배경 레이어 위에 얹는** 도형만 모았다.
|
|
* 좌표 변환과 레이어 렌더는 `B04_PreProcess_UI_MapRender.ts`가 맡는다 — 그 파일이 700줄
|
|
* 한계에 닿아 분리했다.
|
|
* ========================================================================== */
|
|
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import { stationLabel } from "@util/common_util_svg";
|
|
|
|
/** 상류 세류망 강조선 색. 정의처는 `ui_template_theme.css`(`--map-upstream`). */
|
|
const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)");
|
|
import {
|
|
haloColor,
|
|
lonLatToScreen,
|
|
type Normalizer,
|
|
type ViewState,
|
|
} from "./B04_PreProcess_UI_MapRender";
|
|
|
|
export type FilledRing = {
|
|
ring: ReadonlyArray<readonly [number, number]>;
|
|
/**
|
|
* 조각·구멍을 모두 편 링 목록. 주면 even-odd로 한 번에 채워 **구멍이 뚫린다** —
|
|
* 아래 유역이 위 유역을 감싸는 도넛에서 위 유역을 덮지 않는다. 없으면 `ring` 하나만.
|
|
*/
|
|
rings?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>;
|
|
/** 면적 중심에 얹을 번호. 없으면 라벨을 그리지 않는다. */
|
|
label?: string;
|
|
};
|
|
|
|
/**
|
|
* lon/lat 폴리곤 링을 파스텔 채움 + 테두리 + 중심 번호로 그린다.
|
|
* 사전 투영 캐시를 쓰지 않는 소량(유역 수 개) 오버레이 전용이라 매 프레임 변환해도 부담이 없다.
|
|
*/
|
|
export function drawFilledRing(
|
|
context: CanvasRenderingContext2D,
|
|
entry: FilledRing,
|
|
normalizer: Normalizer,
|
|
view: ViewState,
|
|
color: string,
|
|
): void {
|
|
if (entry.ring.length < 3) return;
|
|
const rings = entry.rings?.length ? entry.rings : [entry.ring];
|
|
let sumX = 0;
|
|
let sumY = 0;
|
|
context.beginPath();
|
|
rings.forEach((ring) => {
|
|
if (ring.length < 3) return;
|
|
ring.forEach(([lon, lat], index) => {
|
|
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
|
if (index === 0) context.moveTo(x, y);
|
|
else context.lineTo(x, y);
|
|
});
|
|
context.closePath();
|
|
});
|
|
// 번호 자리는 바깥 링만 보고 잡는다 — 구멍까지 섞으면 중심이 유역 밖으로 밀린다.
|
|
entry.ring.forEach(([lon, lat]) => {
|
|
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
|
sumX += x;
|
|
sumY += y;
|
|
});
|
|
context.fillStyle = color;
|
|
context.fill("evenodd");
|
|
context.strokeStyle = color;
|
|
context.lineWidth = 1.6;
|
|
context.stroke();
|
|
if (!entry.label) return;
|
|
drawRingBadge(context, [sumX / entry.ring.length, sumY / entry.ring.length], color, entry.label);
|
|
}
|
|
|
|
/** 유역 번호 배지 한 개(라운드 사각 + 숫자). 화면 좌표를 그대로 받는다.
|
|
*
|
|
* 채움과 따로 떼어 둔 이유: 번호는 **맨 위에** 있어야 한다. 채움과 함께 그리면 그 위에
|
|
* 얹히는 등고선·화살표·관 마커에 가려 읽을 수 없다(2026-08-01 사용자 지시).
|
|
*
|
|
* 원이 아니라 사각인 이유: 지도 위 다른 숫자들도 전부 동그라미라 크기로만 갈렸다.
|
|
* 형태를 바꿔 한눈에 구분되게 했다 — 크기는 그대로다(2026-08-17 사용자 지시). */
|
|
export function drawRingBadge(
|
|
context: CanvasRenderingContext2D,
|
|
center: readonly [number, number],
|
|
color: string,
|
|
label: string,
|
|
): void {
|
|
const [centerX, centerY] = center;
|
|
const half = 11;
|
|
context.save();
|
|
context.beginPath();
|
|
context.roundRect(centerX - half, centerY - half, half * 2, half * 2, 6);
|
|
context.fillStyle = color;
|
|
context.fill();
|
|
context.strokeStyle = haloColor();
|
|
context.lineWidth = 1.5;
|
|
context.stroke();
|
|
context.font = "600 12px sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.fillStyle = themeColor("--map-label-text", "#1f2937");
|
|
context.fillText(label, centerX, centerY);
|
|
context.restore();
|
|
}
|
|
|
|
/** 폴리곤 정점 평균의 화면 좌표 — 배지를 얹을 자리. */
|
|
/**
|
|
* 점이 조각·구멍으로 이루어진 유역 안에 있는가 — 링마다 홀짝을 뒤집는 even-odd 판정.
|
|
* 구멍(안에 든 다른 유역) 안을 누르면 바깥 유역이 잡히지 않는다.
|
|
*/
|
|
export function pointInRings(
|
|
rings: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
|
|
x: number,
|
|
y: number,
|
|
): boolean {
|
|
let inside = false;
|
|
for (const ring of rings) {
|
|
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
|
const [xi, yi] = ring[index];
|
|
const [xj, yj] = ring[previous];
|
|
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
|
}
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
export function ringCenterOnScreen(
|
|
ring: ReadonlyArray<readonly [number, number]>,
|
|
normalizer: Normalizer,
|
|
view: ViewState,
|
|
): [number, number] {
|
|
let sumX = 0;
|
|
let sumY = 0;
|
|
ring.forEach(([lon, lat]) => {
|
|
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
|
sumX += x;
|
|
sumY += y;
|
|
});
|
|
return [sumX / ring.length, sumY / ring.length];
|
|
}
|
|
|
|
/** 상류 세류망 강조 — B04 분석 오버레이와 B05 배수유역도가 같은 굵기·색으로 그린다. */
|
|
export function drawUpstreamLines(
|
|
context: CanvasRenderingContext2D,
|
|
lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>,
|
|
normalizer: Normalizer,
|
|
view: ViewState,
|
|
): void {
|
|
if (lines.length === 0) return;
|
|
context.save();
|
|
context.setLineDash([]);
|
|
context.lineWidth = 4;
|
|
context.lineCap = "round";
|
|
context.lineJoin = "round";
|
|
context.strokeStyle = upstreamLineColor();
|
|
lines.forEach((line) => {
|
|
if (line.length < 2) return;
|
|
context.beginPath();
|
|
line.forEach(([lon, lat], index) => {
|
|
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
|
if (index === 0) context.moveTo(x, y);
|
|
else context.lineTo(x, y);
|
|
});
|
|
context.stroke();
|
|
});
|
|
context.restore();
|
|
}
|
|
|
|
/** 유역 경계(분수령=능선)를 능선 스타일(갈색 파선)로 강조해 그린다. */
|
|
export function drawRidgeRing(
|
|
context: CanvasRenderingContext2D,
|
|
ring: ReadonlyArray<readonly [number, number]>,
|
|
normalizer: Normalizer,
|
|
view: ViewState,
|
|
): void {
|
|
if (ring.length < 3) return;
|
|
context.beginPath();
|
|
ring.forEach(([lon, lat], index) => {
|
|
const [x, y] = lonLatToScreen(normalizer, view, lon, lat);
|
|
if (index === 0) context.moveTo(x, y);
|
|
else context.lineTo(x, y);
|
|
});
|
|
context.closePath();
|
|
context.save();
|
|
context.strokeStyle = themeColor("--map-basin-outline", "#92400e");
|
|
context.lineWidth = 1.8;
|
|
context.setLineDash([7, 4]);
|
|
context.stroke();
|
|
context.restore();
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 계획선 위 측점 눈금·번호 (2026-09-04 사용자 지시)
|
|
*
|
|
* 종단·3D와 같은 `측점번호+잔여거리` 표기다. 배율이 낮으면 글자가 붙으므로 3D 라벨과 같은
|
|
* 단계 규칙으로 솎는다(5칸 → 2칸 → 전부). 관 마커가 있는 측점은 라벨을 계획선 **반대쪽**
|
|
* 으로 밀어 마커를 가리지 않게 한다. B04 지도와 B05 배수유역도가 이 한 곳을 함께 쓴다.
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
export interface StationTickOptions {
|
|
/** 규칙 측점 간격(m). */
|
|
intervalM: number;
|
|
/** 화면 1m 당 픽셀 — 라벨 솎기 단계를 여기서 정한다. */
|
|
pxPerMeter: number;
|
|
toScreen: (x: number, y: number) => [number, number];
|
|
/** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */
|
|
avoidChainages?: ReadonlyArray<number>;
|
|
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다.
|
|
* 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */
|
|
uprightRad?: number;
|
|
}
|
|
|
|
export function drawStationTicks(
|
|
context: CanvasRenderingContext2D,
|
|
points: ReadonlyArray<{ x: number; y: number }>,
|
|
options: StationTickOptions,
|
|
): void {
|
|
if (points.length < 2) return;
|
|
const interval = options.intervalM > 0 ? options.intervalM : 20;
|
|
// 라벨 사이가 좁아지면 솎는다 — 화면에서 잰 간격(px)으로 정한다.
|
|
const gapPx = interval * options.pxPerMeter;
|
|
const step = gapPx >= 90 ? 1 : gapPx >= 40 ? 2 : 5;
|
|
const avoid = options.avoidChainages ?? [];
|
|
|
|
// 정점 누가거리 — 측점 자리는 정점 사이에 떨어지므로 보간해서 찍는다.
|
|
const cumulative: number[] = [0];
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
cumulative.push(
|
|
cumulative[index - 1] +
|
|
Math.hypot(points[index].x - points[index - 1].x, points[index].y - points[index - 1].y),
|
|
);
|
|
}
|
|
const total = cumulative[cumulative.length - 1];
|
|
if (total <= 0) return;
|
|
|
|
context.save();
|
|
context.font = "11px system-ui, sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
// 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과
|
|
// 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다).
|
|
const drawn: Array<{ x: number; y: number; half: number }> = [];
|
|
let cursor = 1;
|
|
for (let chainage = 0; chainage <= total; chainage += interval) {
|
|
const stationNo = Math.round(chainage / interval);
|
|
if (stationNo % step !== 0) continue;
|
|
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
|
const back = points[cursor - 1];
|
|
const front = points[cursor];
|
|
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
|
|
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
|
|
const px = back.x + (front.x - back.x) * ratio;
|
|
const py = back.y + (front.y - back.y) * ratio;
|
|
const [sx, sy] = options.toScreen(px, py);
|
|
const [bx, by] = options.toScreen(back.x, back.y);
|
|
const [fx, fy] = options.toScreen(front.x, front.y);
|
|
const dx = fx - bx;
|
|
const dy = fy - by;
|
|
const length = Math.hypot(dx, dy) || 1;
|
|
// 계획선에 직각인 방향 — 눈금과 라벨을 이 방향으로 놓는다.
|
|
const ux = -dy / length;
|
|
const uy = dx / length;
|
|
const nearPipe = avoid.some((pipe) => Math.abs(pipe - chainage) < interval / 2);
|
|
const side = nearPipe ? -1 : 1;
|
|
|
|
context.beginPath();
|
|
context.moveTo(sx - ux * 6, sy - uy * 6);
|
|
context.lineTo(sx + ux * 6, sy + uy * 6);
|
|
context.lineWidth = 1.2;
|
|
context.strokeStyle = "rgba(40, 40, 40, 0.85)";
|
|
context.stroke();
|
|
|
|
const label = stationLabel(chainage, interval);
|
|
const lx = sx + ux * side * 16;
|
|
const ly = sy + uy * side * 16;
|
|
const width = context.measureText(label).width + 6;
|
|
const half = width / 2;
|
|
const collides = drawn.some(
|
|
(item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16,
|
|
);
|
|
if (collides) continue;
|
|
drawn.push({ x: lx, y: ly, half });
|
|
context.save();
|
|
if (options.uprightRad) {
|
|
context.translate(lx, ly);
|
|
context.rotate(options.uprightRad);
|
|
context.translate(-lx, -ly);
|
|
}
|
|
// 배경을 깔아 등고선 위에서도 읽히게 한다.
|
|
context.fillStyle = "rgba(255, 255, 255, 0.78)";
|
|
context.fillRect(lx - half, ly - 8, width, 16);
|
|
context.fillStyle = "#222222";
|
|
context.fillText(label, lx, ly);
|
|
context.restore();
|
|
}
|
|
context.restore();
|
|
}
|