From e8f2ae02d55f53b3239f042937f0e683739688cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:31:15 +0900 Subject: [PATCH] =?UTF-8?q?fix(B06=20=EC=A2=85=EB=8B=A8):=20=EA=B0=80?= =?UTF-8?q?=EA=B9=8C=EC=9A=B4=20=EC=B8=A1=EC=A0=90=20=C2=B7=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=9D=B4=EB=A6=84=20=EA=B2=B9=EC=B9=98?= =?UTF-8?q?=EB=A9=B4=20=EC=9C=84=20=EB=8B=A8=EC=9C=BC=EB=A1=9C=20=EB=B9=84?= =?UTF-8?q?=EC=BC=9C=20=EA=B7=B8=EB=A6=BC=20(407-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logic/B06_Section_Label_Tiers 새로(labelTiers · labelWidthPx) · 이름은 지우지 않음 · 위 끝 3단 - 올린 측점 이름 = 맨 위 겹 · 바탕색 테두리 · 고른 측점 색 그대로 · 구조물 이름은 올린 측점 이름 위로 - B05 종단도 같은 함수(createLongitudinalProfile) - 시험: test_407_4_label_tiers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B06_Section/B06_Section_UI_Longitudinal.ts | 58 ++++++++++---- B06_Section/B06_Section_UI_Style_Cross.css | 12 ++- B06_Section/logic/B06_Section_Label_Tiers.ts | 31 ++++++++ resources/tester/test_407_4_label_tiers.py | 79 ++++++++++++++++++++ 4 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 B06_Section/logic/B06_Section_Label_Tiers.ts create mode 100644 resources/tester/test_407_4_label_tiers.py diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 86335e0fc..f157b44f7 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -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) => { diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index d463e6851..7b5c83036 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -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); } diff --git a/B06_Section/logic/B06_Section_Label_Tiers.ts b/B06_Section/logic/B06_Section_Label_Tiers.ts new file mode 100644 index 000000000..1d7596dfc --- /dev/null +++ b/B06_Section/logic/B06_Section_Label_Tiers.ts @@ -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; +} diff --git a/resources/tester/test_407_4_label_tiers.py b/resources/tester/test_407_4_label_tiers.py new file mode 100644 index 000000000..9725a290c --- /dev/null +++ b/resources/tester/test_407_4_label_tiers.py @@ -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)