fix(B06 종단): 가까운 측점 · 구조물 이름 겹치면 위 단으로 비켜 그림 (407-4)
- logic/B06_Section_Label_Tiers 새로(labelTiers · labelWidthPx) · 이름은 지우지 않음 · 위 끝 3단 - 올린 측점 이름 = 맨 위 겹 · 바탕색 테두리 · 고른 측점 색 그대로 · 구조물 이름은 올린 측점 이름 위로 - B05 종단도 같은 함수(createLongitudinalProfile) - 시험: test_407_4_label_tiers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DesignProfile, LongitudinalSection } from "./B06_Section_Api_Fetch";
|
||||
import { labelTiers, labelWidthPx } from "./logic/B06_Section_Label_Tiers";
|
||||
import {
|
||||
emptyView,
|
||||
inferStationInterval,
|
||||
@@ -189,6 +190,8 @@ function attachStationDrag(
|
||||
|
||||
/** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */
|
||||
const STATION_DRAG_SLOP_PX = 3;
|
||||
/** 겹친 이름표 한 단 높이(px · 10px 글 + 틈 1). */
|
||||
const LABEL_TIER_PX = 11;
|
||||
|
||||
export function createLongitudinalProfile(
|
||||
data: LongitudinalSection,
|
||||
@@ -392,9 +395,38 @@ export function createLongitudinalProfile(
|
||||
// 구조물 이름은 **맨 마지막에** 붙인다 — 측점 마커 안에 넣으면 뒤이어 그리는 지반선·
|
||||
// 계획선이 그 위를 덮는다(2026-08-02 사용자 지시: 오버레이 순서 최상위).
|
||||
const structureLabels: SVGTextElement[] = [];
|
||||
for (const station of data.stations) {
|
||||
// 가까운 측점끼리 이름이 겹치면 위 단으로 비킴(407-4) — 구조물 이름은 올라간 측점 이름 위로.
|
||||
const stationTexts = data.stations.map(
|
||||
(station) =>
|
||||
offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset) +
|
||||
(station.kind === "curve" ? " (MC)" : ""),
|
||||
);
|
||||
const stationTiers = labelTiers(
|
||||
data.stations.map((station, index) => ({
|
||||
x: x(station.chainage_m),
|
||||
width: labelWidthPx(stationTexts[index], 10),
|
||||
})),
|
||||
);
|
||||
const named = data.stations.filter((station) => station.structure);
|
||||
const namedTiers = labelTiers(
|
||||
named.map((station) => ({
|
||||
x: x(station.chainage_m),
|
||||
width: labelWidthPx(structureDisplayName(station.structure!), 9),
|
||||
})),
|
||||
);
|
||||
const structureLift = LABEL_TIER_PX * Math.max(0, ...stationTiers);
|
||||
data.stations.forEach((station, stationIndex) => {
|
||||
const stationX = x(station.chainage_m);
|
||||
const selected = station.station_id === selectedStationId;
|
||||
const tier = stationTiers[stationIndex];
|
||||
// 올라간 이름 = 선 · 지반선 위에 얹음(맨 끝 겹 · 바탕색 테두리)
|
||||
const label = svgText(stationTexts[stationIndex], {
|
||||
x: stationX,
|
||||
y: heightPx - bottomInsetPx - 5 - LABEL_TIER_PX * tier,
|
||||
"text-anchor": "middle",
|
||||
class: `b06-chart__station-label${tier ? " b06-chart__station-label--lifted" : ""}${tier && selected ? " b06-chart__station-label--selected" : ""}`,
|
||||
});
|
||||
if (tier) structureLabels.push(label);
|
||||
const marker = svgElement("g", {
|
||||
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
|
||||
// B05 [직선화]·[쉬프트] 도구가 클릭한 측점의 누가거리를 여기서 읽는다(2026-09-02).
|
||||
@@ -426,19 +458,10 @@ export function createLongitudinalProfile(
|
||||
y2: heightPx - padBottom,
|
||||
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.plus ? "plus" : station.kind}`,
|
||||
}),
|
||||
// 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점
|
||||
// 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시).
|
||||
svgText(
|
||||
offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset) +
|
||||
(station.kind === "curve" ? " (MC)" : ""),
|
||||
{
|
||||
x: stationX,
|
||||
y: heightPx - bottomInsetPx - 5,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__station-label",
|
||||
},
|
||||
),
|
||||
);
|
||||
// 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점
|
||||
// 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시).
|
||||
if (!tier) marker.append(label);
|
||||
// 구조물(비정규) 측점: 구조물 이름으로 위치를 식별할 수 있게 한다.
|
||||
// 플롯 **하단**(측점 라벨 바로 위)에 둔다 — 상단은 그래프 이름표와 절·성토 표기가
|
||||
// 이미 쓰고 있어 서로 겹쳤다(2026-08-02 사용자 지시).
|
||||
@@ -447,14 +470,19 @@ export function createLongitudinalProfile(
|
||||
// 배수관은 관종·관경 대신 표시 이름 하나로 적는다(2026-09-03 — 횡단 카드와 같은 규칙).
|
||||
svgText(structureDisplayName(station.structure), {
|
||||
x: stationX,
|
||||
y: heightPx - padBottom - 5,
|
||||
y:
|
||||
heightPx -
|
||||
padBottom -
|
||||
5 -
|
||||
structureLift -
|
||||
LABEL_TIER_PX * namedTiers[named.indexOf(station)],
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__structure-label",
|
||||
}),
|
||||
);
|
||||
}
|
||||
svg.append(marker);
|
||||
}
|
||||
});
|
||||
|
||||
const points = samples
|
||||
.map((sample) => {
|
||||
|
||||
@@ -408,6 +408,15 @@ html.is-bm-route .b06-section__notice-bar {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* 겹쳐 위 단으로 올린 측점 이름(407-4) — 선 · 지반선 위라 바탕색 테두리로 읽히게. */
|
||||
.b06-chart__station-label--lifted {
|
||||
paint-order: stroke;
|
||||
stroke: var(--color-surface-raised);
|
||||
stroke-width: 3px;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */
|
||||
.b06-chart__structure-label {
|
||||
font-size: 9px;
|
||||
@@ -517,7 +526,8 @@ html.is-bm-route .b06-section__notice-bar {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
}
|
||||
|
||||
.b06-chart__station--selected .b06-chart__station-label {
|
||||
.b06-chart__station--selected .b06-chart__station-label,
|
||||
.b06-chart__station-label--selected {
|
||||
fill: var(--color-danger);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* =============================================================================
|
||||
* B06_Section/logic/B06_Section_Label_Tiers.ts
|
||||
* 종단 측점 · 구조물 이름표 비켜 그리기(PLAN 407-4) — 가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단으로
|
||||
* 올림 · 이름은 지우지 않음. 그리는 곳 = `B06_Section_UI_Longitudinal`(B05 종단도 같은 함수).
|
||||
* 시험 `resources/tester/test_407_4_label_tiers.py`. 화면 · 세션 없음(순수).
|
||||
* ========================================================================== */
|
||||
|
||||
/** 단 수 위 끝 — 넘으면 마지막 단에 겹쳐 둠(이름은 남김). */
|
||||
export const MAX_LABEL_TIER = 3;
|
||||
|
||||
/** 글 폭 짐작(px) — 한글 · 한자 = 글자 크기 · 그 밖 = 0.6 배. */
|
||||
export const labelWidthPx = (text: string, fontPx: number): number =>
|
||||
[...text].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 0x2e80 ? fontPx : fontPx * 0.6), 0);
|
||||
|
||||
/** 가운데 맞춤 이름표마다 단(0 = 제자리) — 왼쪽부터 겹치지 않는 가장 낮은 단 · `gapPx` = 글 사이 틈. */
|
||||
export function labelTiers(
|
||||
items: ReadonlyArray<{ x: number; width: number }>,
|
||||
gapPx = 3,
|
||||
): number[] {
|
||||
const order = items.map((_, index) => index).sort((a, b) => items[a].x - items[b].x);
|
||||
const rightEdge: number[] = []; // 단마다 마지막 이름표 오른쪽 끝
|
||||
const tiers = items.map(() => 0);
|
||||
for (const index of order) {
|
||||
const { x, width } = items[index];
|
||||
let tier = rightEdge.findIndex((edge) => edge + gapPx <= x - width / 2);
|
||||
if (tier < 0) tier = Math.min(rightEdge.length, MAX_LABEL_TIER);
|
||||
rightEdge[tier] = Math.max(rightEdge[tier] ?? -Infinity, x + width / 2);
|
||||
tiers[index] = tier;
|
||||
}
|
||||
return tiers;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"""PLAN 407-4 — 종단 측점 · 구조물 이름표 비켜 그리기(`logic/B06_Section_Label_Tiers.ts`).
|
||||
|
||||
가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단 · 안 겹치면 제자리(0) · 이름은 지우지 않음 ·
|
||||
단 수 위 끝(`MAX_LABEL_TIER`) 넘으면 마지막 단 · 글 폭 짐작(한글 = 글자 크기).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS = ROOT / "B06_Section" / "logic" / "B06_Section_Label_Tiers.ts"
|
||||
TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript"
|
||||
|
||||
SCRIPT = r"""
|
||||
const ts = require(process.argv[1]);
|
||||
const fs = require("fs");
|
||||
const js = ts.transpileModule(fs.readFileSync(process.argv[2], "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", js)(box.exports, box);
|
||||
const m = box.exports;
|
||||
const cases = JSON.parse(process.argv[3]);
|
||||
process.stdout.write(JSON.stringify({
|
||||
max: m.MAX_LABEL_TIER,
|
||||
tiers: cases.map((items) => m.labelTiers(items)),
|
||||
widths: ["19+18 (MC)", "20+0", "배수관"].map((t) => m.labelWidthPx(t, 10)),
|
||||
}));
|
||||
"""
|
||||
|
||||
W = 40.0 # 이름표 폭(px)
|
||||
CASES = [
|
||||
[{"x": 100, "width": W}, {"x": 200, "width": W}, {"x": 300, "width": W}], # 안 겹침
|
||||
[{"x": 100, "width": W}, {"x": 108, "width": 24}], # 1.6 m 이웃 — 겹침
|
||||
[{"x": 108, "width": 24}, {"x": 100, "width": W}], # 차례 뒤바뀜(x 로 셈)
|
||||
[{"x": 100 + i * 2, "width": W} for i in range(6)], # 여섯 겹 — 위 끝
|
||||
[{"x": 100, "width": W}, {"x": 110, "width": W}, {"x": 150, "width": W}], # 셋째는 제자리
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS), json.dumps(CASES)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_안_겹치면_제자리(r) -> None:
|
||||
assert r["tiers"][0] == [0, 0, 0]
|
||||
|
||||
|
||||
def test_겹치면_위_단(r) -> None:
|
||||
assert r["tiers"][1] == [0, 1]
|
||||
assert r["tiers"][2] == [1, 0] # 왼쪽 것이 제자리
|
||||
|
||||
|
||||
def test_위_끝_넘으면_마지막_단(r) -> None:
|
||||
assert r["tiers"][3] == [0, 1, 2, 3, 3, 3] # 이름은 남음(지우지 않음)
|
||||
|
||||
|
||||
def test_빈_단은_다시_씀(r) -> None:
|
||||
assert r["tiers"][4] == [0, 1, 0]
|
||||
|
||||
|
||||
def test_글_폭(r) -> None:
|
||||
mc, plain, korean = r["widths"]
|
||||
assert mc == pytest.approx(60.0) and plain == pytest.approx(24.0)
|
||||
assert korean == pytest.approx(30.0)
|
||||
Reference in New Issue
Block a user