feat(profile): 종단 알약 글 — 기타 = 손 이름 머리 + 번호 · 빈 이름 = 기타 N(54-9)

- 기타 알약 = 손 이름 앞 3글자(빈칸 뺌 · 다른 손 이름과 머리가 같으면 갈릴 때까지 늘림) + 목록과 같은 번호
- 빈 이름 = 「기타 N」 · 다른 종류 알약 · 툴팁 · 목록 이름 그대로
- structurePillTexts 한 곳이 B05 · B06 알약을 함께 그림 · 시험 test_54_9_etc_pill

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA
This commit is contained in:
2026-10-06 10:36:05 +09:00
co-authored by Claude Opus 5.5
parent 3bd18875f0
commit 23cf91e2df
3 changed files with 225 additions and 19 deletions
+56 -19
View File
@@ -38,6 +38,8 @@ const ROW_GAP_PX = 2;
const LANE_PADDING_PX = 2;
/** 두 알약이 겹친다고 볼 가로 거리(px). 3자 라벨 폭 기준. */
const OVERLAP_PX = 52;
/** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */
const ETC_PILL_CHARS = 3;
/** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다).
* 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을
@@ -115,6 +117,53 @@ function rowAssignments(
return rows;
}
/** 이름마다 남과 갈리는 가장 짧은 머리 — 빈칸 뺌 · 적어도 3글자 · 이름보다 길지 않음. */
function distinctHeads(names: ReadonlyArray<string>): Map<string, string> {
const letters = names.map((name) => [...name.replace(/\s+/g, "")]);
return new Map(
names.map((name, index) => {
const own = letters[index];
let length = ETC_PILL_CHARS;
letters.forEach((other, otherIndex) => {
if (otherIndex === index) return;
let common = 0;
while (common < own.length && own[common] === other[common]) common += 1;
length = Math.max(length, common + 1);
});
return [name, own.slice(0, length).join("")];
}),
);
}
/** 표시 이름 · 알약 글(구조물 id 별). 표시 이름 = 좌측 목록과 같은 중복 번호(시점에 가까운 순
* "이름 1/2…", 2026-08-19) — 툴팁 · 벌룬 제목이 씀. 알약 = 약호 + 그 번호. 기타는 약호가 모두
* 「기타」라 손 이름 머리(distinctHeads) + 번호 · 빈 이름 = 「기타 N」(54-9). */
export function structurePillTexts(
structures: ReadonlyArray<StructureInstance>,
typeMap: ReadonlyMap<string, StructureType>,
): Map<string, { name: string; pill: string }> {
const sorted = [...structures].sort(
(left, right) => structureAnchorM(left) - structureAnchorM(right),
);
const rawNames = sorted.map(
(entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id,
);
const numbered = numberDuplicateNames(rawNames);
const heads = distinctHeads([
...new Set(rawNames.filter((_name, index) => sorted[index].type_id === "etc")),
]);
return new Map(
sorted.map((entry, index) => {
const name = numbered[index];
const suffix = name.slice(rawNames[index].length); // " 1" 또는 ""
const head = etcName(entry)
? heads.get(rawNames[index])
: typeMap.get(entry.type_id)?.style?.abbr;
return [entry.structure_id ?? "", { name, pill: `${head ?? "?"}${suffix}` }];
}),
);
}
/**
* 그래프 아래에 구조물 알약 레인을 만들어 돌려준다(호출부가 원하는 위치에 붙인다).
*/
@@ -127,28 +176,15 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
const typeMap = new Map(options.types.map((type) => [type.type_id, type]));
const rows = rowAssignments(options.structures, options.x);
// 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 시점에 가까운 순
// "이름 1/2…". 알약 라벨(약호)·툴팁·벌룬 제목이 모두 이 번호를 따른다.
const anchorSorted = [...options.structures].sort(
(left, right) => structureAnchorM(left) - structureAnchorM(right),
);
const numberedNames = numberDuplicateNames(
anchorSorted.map(
(entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id,
),
);
const displayNames = new Map(
anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]),
);
// 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이
// 모두 이 번호를 따른다.
const texts = structurePillTexts(options.structures, typeMap);
options.structures.forEach((structure) => {
const id = structure.structure_id;
if (!id) return;
const type = typeMap.get(structure.type_id);
const rawName = etcName(structure) ?? type?.name ?? structure.type_id; // 기타 = 손 이름(목록과 같게)
const displayName = displayNames.get(id) ?? rawName;
// "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지).
const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : "";
const { name: displayName, pill } = texts.get(id) ?? { name: structure.type_id, pill: "?" };
const anchorPx = options.x(structureAnchorM(structure));
const selected = options.selectedId === id;
// 이웃과 겹치는 알약만 위·아래 두 줄로 가르고, 혼자면 레인 세로 가운데에 크게 둔다.
@@ -185,8 +221,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
mark.style.top = `${top}px`;
mark.style.borderColor = type?.style?.color ?? "#888";
mark.style.color = type?.style?.color ?? "#888";
// 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호.
mark.textContent = `${type?.style?.abbr ?? "?"}${numberSuffix}`;
// 라벨은 3자 이하 짧은 이름(레지스트리 style.abbr, 2026-08-17 지시 4) + 중복 번호 ·
// 기타는 손 이름 머리(54-9).
mark.textContent = pill;
mark.title = `${displayName} ${positionText(structure, options.stationIntervalM)}`;
mark.dataset.structureId = id;
+95
View File
@@ -0,0 +1,95 @@
/* 54-9 종단 알약 글 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다.
* 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다(세션 저장소만 흉내).
*
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다.
*/
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 session = new Map();
const fakeStorage = {
getItem: (k) => session.get(k) ?? null,
setItem: (k, v) => session.set(k, String(v)),
removeItem: (k) => session.delete(k),
};
globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage };
globalThis.sessionStorage = fakeStorage;
globalThis.localStorage = fakeStorage;
const STUBS = {
"@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 },
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} });
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const marks = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts"));
const typeMap = new Map([
["etc", { type_id: "etc", name: "기타", style: { abbr: "기타" }, options: [] }],
[
"retaining_wall",
{ type_id: "retaining_wall", name: "옹벽", style: { abbr: "옹" }, options: [] },
],
]);
const etc = (id, chainage, name) => ({
structure_id: id,
type_id: "etc",
placement: "point",
chainage_m: chainage,
options: name === undefined ? {} : { name },
});
const wall = (id, chainage) => ({
structure_id: id,
type_id: "retaining_wall",
placement: "point",
chainage_m: chainage,
options: {},
});
const run = (structures) =>
Object.fromEntries(
[...marks.structurePillTexts(structures, typeMap)].map(([id, text]) => [id, text]),
);
const out = {
// 53-2 시나리오 — 같은 이름 둘 · 다른 이름 · 빈 이름 둘(빈칸 이름 = 빈 이름)
scenario: run([
etc("e1", 40, "배수로정비"),
etc("e2", 60, "울타리"),
etc("e3", 80, ""),
etc("e4", 100, "배수로정비"),
etc("e5", 120, " "),
]),
// 머리가 같은 다른 이름 — 갈릴 때까지 늘림 · 이름이 남의 머리면 그 이름 그대로
prefix: run([
etc("p1", 10, "콘크리트 블록"),
etc("p2", 20, "콘크리트 옹벽"),
etc("p3", 30, "배수로"),
etc("p4", 40, "배수로공"),
etc("p5", 50, "기타 시설"),
etc("p6", 60),
]),
// 다른 종류 규칙은 그대로 — 약호 + 중복 번호 · 기타 하나 빈 이름 = 「기타」
others: run([wall("w1", 10), wall("w2", 30), etc("x1", 20, "가로등"), etc("x2", 40)]),
};
process.stdout.write(JSON.stringify(out));
+74
View File
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""54-9 종단 알약 글 — 기타가 여럿이어도 알약끼리 구별됨.
• 손 이름 앞 3글자 · 같은 이름이면 목록과 같은 번호 · 빈 이름 = 「기타 N」
• 머리가 같은 다른 이름은 갈릴 때까지 늘림
• 다른 종류 알약(약호 + 중복 번호) · 툴팁 · 목록 이름(손 이름 그대로)은 그대로
화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_9_etc_pill.cjs`).
B05 · B06 은 같은 알약 레인(`buildStructureLane`)을 씀 — 실제 화면은 ORCA 로 따로 본다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_54_9_etc_pill.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_53_2_기타_다섯_알약이_서로_다르다(out):
pills = {key: value["pill"] for key, value in out["scenario"].items()}
assert pills == {
"e1": "배수로 1",
"e2": "울타리",
"e3": "기타 1",
"e4": "배수로 2",
"e5": "기타 2",
}
assert len(set(pills.values())) == 5
def test_툴팁_이름은_손_이름_그대로(out):
names = [value["name"] for value in out["scenario"].values()]
assert names == ["배수로정비 1", "울타리", "기타 1", "배수로정비 2", "기타 2"]
def test_머리가_같은_다른_이름은_갈릴_때까지_늘린다(out):
pills = {key: value["pill"] for key, value in out["prefix"].items()}
assert pills == {
"p1": "콘크리트블",
"p2": "콘크리트옹",
"p3": "배수로",
"p4": "배수로공",
"p5": "기타시",
"p6": "기타",
}
def test_다른_종류_알약은_약호와_번호_그대로(out):
pills = {key: value["pill"] for key, value in out["others"].items()}
assert pills == {"w1": "옹 1", "w2": "옹 2", "x1": "가로등", "x2": "기타"}
def test_알약_레인이_글을_한_곳에서_받는다():
with open(
os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts"), encoding="utf-8"
) as handle:
text = handle.read()
assert "mark.textContent = pill;" in text
assert text.count("structurePillTexts(") == 2 # 정의 하나 · 레인 하나