feat(B02/B03/B04/B05): 계획노선 사용 범위 · 절단 여유 3m · 배수유역도 줌·측점 표기
- 계획노선 사용 범위: B02 등록에 시작·종료 누가거리 두 칸 추가, B01 수정 모달에서도 변경. projects.route_start_m·route_end_m 신설(015_route_range.sql). load_design_route 가 범위 절단 → 서피스 트림 순서로 적용. 시작 >= 종료는 화면·서버 양쪽에서 차단. 비우면 전 구간으로 종전과 같음. - 서피스 절단 여유 기본값 30m → 3m (SURFACE_ROUTE_EDGE_TRIM_M). - B04 지도·B05 배수유역도 줌 상한을 「화면 폭 20m」 기준으로 계산(고정 8배·16배 폐지). 4배를 넘으면 배경 그림 흐림 보간 해제. - 계획선 위 측점 눈금·번호 표기(측점번호+잔여거리). 관 마커와 겹치면 반대쪽으로 밀고, 되꺾임 구간에서 라벨이 겹치면 건너뜀. 그리기 코드는 두 화면 공용. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
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)");
|
||||
@@ -184,3 +185,100 @@ export function drawRidgeRing(
|
||||
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>;
|
||||
}
|
||||
|
||||
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.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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user