From df05b1a1f322f8dc9e764f7c5bce2975c04bfd84 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 12 Oct 2026 21:53:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(b06):=20=EB=AC=BC=EB=84=98=EC=9D=B4=20?= =?UTF-8?q?=EC=9B=94=EB=A5=98=20=ED=8F=AD=EC=9C=BC=EB=A1=9C=20=EC=98=86=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EC=B9=B4=EB=93=9C=EC=97=90=20=EA=B1=B8?= =?UTF-8?q?=EC=B9=9C=20=ED=8F=AC=EC=9E=A5=20=3D=20=ED=9D=99=EB=A7=89?= =?UTF-8?q?=EC=9D=B4=20=EC=97=B0=EB=8F=99=20=EA=BC=B4=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=ED=91=9C=EA=B8=B0=20=C2=B7=20=ED=98=B8=EB=B2=84=20=EC=A3=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=B8=A1=EC=A0=90=20(294-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../B06_Section_UI_Cross_Ford_Linked.ts | 33 ++++ B06_Section/B06_Section_UI_Cross_View.ts | 4 + .../B06_Section_UI_Style_Cross_Areas.css | 11 ++ .../tester/helper_294_6_ford_linked_mark.cjs | 142 ++++++++++++++++++ .../tester/test_294_6_ford_linked_mark.py | 81 ++++++++++ 5 files changed, 271 insertions(+) create mode 100644 B06_Section/B06_Section_UI_Cross_Ford_Linked.ts create mode 100644 resources/tester/helper_294_6_ford_linked_mark.cjs create mode 100644 resources/tester/test_294_6_ford_linked_mark.py diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Linked.ts b/B06_Section/B06_Section_UI_Cross_Ford_Linked.ts new file mode 100644 index 000000000..36c23aa89 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Ford_Linked.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Ford_Linked.ts + * 물넘이포장 연결 표기(294-6) — 월류 폭으로 옆 측점 카드에 걸쳐 그린 포장층 · 월류 높이선을 + * 흙막이 연동 형상(79-6 `.is-linked`)과 같은 꼴로 · 호버 글에 주인 측점. 주인 측점 카드는 그대로 · + * 고름 표식(`data-structure-id`)은 주인 구조물 그대로. + * ========================================================================== */ + +import { stationLabel } from "../common_util/common_util_station"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 연결 꼴을 입힐 물넘이 그림 — 포장층 · 월류 높이선 */ +const LINKED_CLASSES = ["b06-chart__ford-pavement", "b06-chart__ford-deck-plan"]; + +/** `from` 번째 자식부터(방금 그린 물넘이포장) 옆 카드면 연결 꼴 · 주인 측점 글. 붙였으면 true */ +export function markLinkedFordPavement( + svg: SVGElement, + from: number, + section: Pick, + stationInterval: number, +): boolean { + const owner = section.ford_pavement?.chainage_m; + if (typeof owner !== "number" || Math.abs(owner - section.chainage_m) < 1e-3) return false; + const name = `물넘이포장 · ${stationLabel(owner, stationInterval)} 연결`; + for (const el of [...svg.children].slice(from)) { + if (!LINKED_CLASSES.some((className) => el.classList.contains(className))) continue; + el.classList.add("is-linked"); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = name; + el.append(title); + } + return true; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 7f797e240..fd4fb2cee 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -8,6 +8,7 @@ * ========================================================================== */ import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard"; +import { markLinkedFordPavement } from "./B06_Section_UI_Cross_Ford_Linked"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { appendCrossAreaBands, @@ -446,6 +447,7 @@ export function createCrossSectionCard( // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. // 물넘이포장은 노면 자체가 파인 자리라 포장층도 그 바닥을 따른다 — 그렸으면 일반 // 포장 박스는 건너뛴다(두 겹으로 깔리면 색 구분이 사라진다). + const fordFrom = plotLayer.children.length; const fordPaved = appendFordPavementOverlay( plotLayer, section.ford_pavement, @@ -453,6 +455,8 @@ export function createCrossSectionCard( x, toDisplayY, ); + // 월류 폭으로 걸친 옆 카드 = 흙막이 연동 꼴 · 주인 측점 글(294-6) + markLinkedFordPavement(plotLayer, fordFrom, section, stationInterval); if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement); // 맹암거(288-1) — 노면 아래 암거 높이만큼 돌 무늬 형상 diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index d875d8a78..434e10a85 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -316,6 +316,17 @@ stroke-dasharray: 4 2; } +/* 월류 폭으로 옆 측점에 걸친 물넘이포장(294-6) — 연동 형상과 같은 꼴 · 고름 강조(is-active)가 먼저. */ +.b06-chart__ford-pavement.is-linked:not(.is-active) { + fill: color-mix(in srgb, #6f8696 30%, transparent); + stroke: #6f8696; + stroke-dasharray: 4 2; +} + +.b06-chart__ford-deck-plan.is-linked { + stroke: #6f8696; +} + .b06-chart__culvert-revet.is-active { fill: color-mix(in srgb, var(--color-danger) 45%, transparent); stroke: var(--color-danger); diff --git a/resources/tester/helper_294_6_ford_linked_mark.cjs b/resources/tester/helper_294_6_ford_linked_mark.cjs new file mode 100644 index 000000000..98dc452df --- /dev/null +++ b/resources/tester/helper_294_6_ford_linked_mark.cjs @@ -0,0 +1,142 @@ +/* 294-6 물넘이 연결 표기 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로. + * 옆 카드(물넘이 측점 ≠ 카드 측점) · 주인 카드에 물넘이포장을 그리고 연결 꼴 · 호버 글 · 고름 표식을 잼. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const load = (file) => { + if (cache.has(file)) return cache.get(file); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`)); + if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`)); + return new Proxy({}, { get: () => () => {} }); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +}; + +// DOM 흉내 — 속성 · 자식 · classList · 글만 +class Node { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.dataset = {}; + this.style = {}; + this.textContent = ""; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + hasAttribute(k) { + return k in this.attrs; + } + append(...kids) { + this.children.push(...kids); + } + get classList() { + const names = () => (this.attrs.class ?? "").split(" ").filter(Boolean); + const set = (list) => (this.attrs.class = list.join(" ")); + return { + contains: (c) => names().includes(c), + add: (c) => names().includes(c) || set([...names(), c]), + toggle: (c, on) => + set(on ? [...names().filter((n) => n !== c), c] : names().filter((n) => n !== c)), + }; + } + querySelectorAll(selector) { + const attr = /^\[([\w-]+)\]$/.exec(selector)?.[1]; + const all = this.children.flatMap((k) => [k, ...k.querySelectorAll(selector)]); + return all.filter((k) => attr && k.hasAttribute(attr)); + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; + +const B06 = (f) => path.join(ROOT, "B06_Section", f); +const pave = load(B06("B06_Section_UI_Cross_Ford_Pavement.ts")); +const linked = load(B06("B06_Section_UI_Cross_Ford_Linked.ts")); +const shapePick = load(B06("B06_Section_UI_Cross_Shape_Pick.ts")); + +const design = { + section_mode: "left_cut", + tilted: true, + cross_slope_pct: 5, + design_elevation_m: 100, + pavement_thickness_m: 0.2, + paved: true, + road_edges: { + left: { offset_m: 3, elevation_m: 100.15 }, + right: { offset_m: -3, elevation_m: 99.85 }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: 100.125 }, + right: { offset_m: -2.5, elevation_m: 99.875 }, + }, + design_line: [ + { offset_m: 6, elevation_m: 103 }, + { offset_m: 3, elevation_m: 100.15 }, + { offset_m: -3, elevation_m: 99.85 }, + { offset_m: -7, elevation_m: 97.2 }, + ], +}; +// 검증 프로젝트 꼴 — 4+10(물넘이 · 폭 10) → 4+15 카드에도 포장 · 측점 간격 20 +const spec = { chainage_m: 90, span_m: 10, depth_m: 0.4, slope_pct: 5 }; + +const draw = (chainage) => { + const svg = new Node("g"); + const before = new Node("polyline"); // 앞서 그린 것(세월교 점선 등) — 연결 꼴 밖 + before.setAttribute("class", "b06-chart__ford-deck-plan"); + svg.append(before); + const from = svg.children.length; + pave.appendFordPavementOverlay( + svg, + spec, + design, + (o) => o, + (e) => e, + ); + const marked = linked.markLinkedFordPavement( + svg, + from, + { ford_pavement: spec, chainage_m: chainage }, + 20, + ); + const shapes = svg.children + .slice(from) + .filter((k) => + ["b06-chart__ford-pavement", "b06-chart__ford-deck-plan"].some((c) => + k.classList.contains(c), + ), + ); + shapePick.highlightShapes(svg, "pipe-90.00"); + return { + marked, + classes: shapes.map((k) => k.attrs.class), + titles: shapes.map((k) => + k.children.filter((c) => c.tag === "title").map((c) => c.textContent), + ), + pick: shapes + .filter((k) => k.hasAttribute("data-structure-id")) + .map((k) => k.attrs["data-structure-id"]), + beforeClass: before.attrs.class, + }; +}; + +process.stdout.write( + JSON.stringify({ + neighbor: draw(95), + owner: draw(90), + noSpec: linked.markLinkedFordPavement(new Node("g"), 0, { chainage_m: 95 }, 20), + }), +); diff --git a/resources/tester/test_294_6_ford_linked_mark.py b/resources/tester/test_294_6_ford_linked_mark.py new file mode 100644 index 000000000..f825fa3fb --- /dev/null +++ b/resources/tester/test_294_6_ford_linked_mark.py @@ -0,0 +1,81 @@ +"""294-6 물넘이 월류 폭으로 옆 측점 카드에 그려진 포장 = 연결 표기(요구 190-3). + +못박는 것 + - 옆 카드(물넘이 측점 ≠ 카드 측점) = 포장층 · 월류 높이선에 `is-linked`(흙막이 연동 꼴) · + 호버 글 「물넘이포장 · 4+10 연결」 · 앞서 그린 것(세월교 점선)은 그대로. + - 주인 카드 = 옛 꼴(연결 표식 · 글 없음). + - 고름 = 주인 구조물 그대로(`data-structure-id` pipe-90.00 · 강조 켜짐) · CSS 연결 꼴이 강조를 안 덮음. + - 카드 그리기(`Cross_View`)가 물넘이포장 바로 뒤에 표기를 부름. + +진짜 TS 는 `helper_294_6_ford_linked_mark.cjs` 가 Node 로 돌림. 화면은 브레인 확인. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_294_6_ford_linked_mark.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_옆_카드_연결_꼴_호버_글(out): + neighbor = out["neighbor"] + assert neighbor["marked"] is True + assert len(neighbor["classes"]) == 2 + assert all("is-linked" in c.split() for c in neighbor["classes"]) + assert neighbor["titles"] == [["물넘이포장 · 4+10 연결"]] * 2 + assert neighbor["beforeClass"] == "b06-chart__ford-deck-plan" + + +def test_주인_카드_옛_꼴(out): + owner = out["owner"] + assert owner["marked"] is False + assert not any("is-linked" in c.split() for c in owner["classes"]) + assert owner["titles"] == [[], []] + assert out["noSpec"] is False + + +def test_고름은_주인_구조물(out): + for card in (out["neighbor"], out["owner"]): + assert card["pick"] == ["pipe-90.00"] + assert "is-active" in card["classes"][0].split() + + +def test_CSS_연결_꼴_강조가_먼저(): + css = (ROOT / "B06_Section" / "B06_Section_UI_Style_Cross_Areas.css").read_text( + encoding="utf-8" + ) + assert ".b06-chart__ford-pavement.is-linked:not(.is-active)" in css + assert ".b06-chart__ford-deck-plan.is-linked" in css + + +def test_카드가_물넘이포장_뒤에_표기_부름(): + view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8") + start = view.index("const fordFrom = plotLayer.children.length;") + call = view.index("appendFordPavementOverlay(", start) + mark = view.index( + "markLinkedFordPavement(plotLayer, fordFrom, section, stationInterval);", call + ) + assert start < call < mark