diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 0fc00d26a..a2a03a295 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -37,10 +37,9 @@ const SOLO_PILL_HEIGHT_PX = 22; const ROW_GAP_PX = 2; /** 레인 위아래 여백(px) — 그래프를 밀어내되 여유는 최소로. */ const LANE_PADDING_PX = 2; -/** 두 알약이 겹친다고 볼 가로 거리(px). 3자 라벨 폭 기준. */ -const OVERLAP_PX = 52; -/** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */ -const ETC_PILL_CHARS = 3; +/** 알약 폭 어림(px) — 글자당 · 좌우 여백+테(54-49 이름 글이라 폭이 제각각). */ +const PILL_CHAR_PX = 11; +const PILL_PAD_PX = 20; /** 라벨을 위로 비키는 높이(px) — 그래프 바닥 ▼ 단추(바닥 2 + 높이 17) + 틈 2(54-14). */ const EDIT_BUTTON_CLEARANCE_PX = 21; @@ -83,19 +82,24 @@ function positionText(structure: StructureInstance, intervalM: number): string { function rowAssignments( structures: ReadonlyArray, x: (chainageM: number) => number, + labelOf: (structure: StructureInstance) => string, ): Map { const rows = new Map(); const sorted = [...structures].sort( (left, right) => structureAnchorM(left) - structureAnchorM(right), ); const centers = sorted.map((structure) => x(structureAnchorM(structure))); + const halves = sorted.map( + (structure) => ([...labelOf(structure)].length * PILL_CHAR_PX + PILL_PAD_PX) / 2, + ); let row = 0; sorted.forEach((structure, index) => { const id = structure.structure_id ?? ""; const near = - (index > 0 && centers[index] - centers[index - 1] < OVERLAP_PX) || - (index < sorted.length - 1 && centers[index + 1] - centers[index] < OVERLAP_PX); + (index > 0 && centers[index] - centers[index - 1] < halves[index] + halves[index - 1]) || + (index < sorted.length - 1 && + centers[index + 1] - centers[index] < halves[index] + halves[index + 1]); if (!near) { // 앞뒤 어디와도 안 겹친다 — 혼자이므로 레인 세로 가운데. rows.set(id, null); @@ -110,27 +114,8 @@ function rowAssignments( return rows; } -/** 이름마다 남과 갈리는 가장 짧은 머리 — 빈칸 뺌 · 적어도 3글자 · 이름보다 길지 않음. */ -function distinctHeads(names: ReadonlyArray): Map { - 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). */ +/** 표시 이름 · 알약 글(구조물 id 별) — 둘 다 좌측 「종류」 칸 · 목록과 같은 이름(54-49). + * 같은 이름이 여럿이면 시점에 가까운 순 「이름 1/2…」 · 기타 = 손 이름(빈 이름 = 「기타」). */ export function structurePillTexts( structures: ReadonlyArray, typeMap: ReadonlyMap, @@ -138,22 +123,14 @@ export function structurePillTexts( 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( + 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}` }]; - }), + sorted.map((entry, index) => [ + entry.structure_id ?? "", + { name: numbered[index], pill: numbered[index] }, + ]), ); } @@ -167,11 +144,14 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement lane.style.height = `${STRUCTURE_LANE_HEIGHT_PX}px`; const typeMap = new Map(options.types.map((type) => [type.type_id, type])); - const rows = rowAssignments(options.structures, options.x); - // 좌측 목록과 같은 중복 이름 번호(2026-08-19 사용자 지시) — 알약 글·툴팁·벌룬 제목이 // 모두 이 번호를 따른다. const texts = structurePillTexts(options.structures, typeMap); + const rows = rowAssignments( + options.structures, + options.x, + (structure) => texts.get(structure.structure_id ?? "")?.pill ?? "", + ); options.structures.forEach((structure) => { const id = structure.structure_id; @@ -214,8 +194,7 @@ 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) + 중복 번호 · - // 기타는 손 이름 머리(54-9). + // 라벨 = 「종류」 칸과 같은 이름 + 중복 번호(54-49). mark.textContent = pill; mark.title = `${displayName} ${positionText(structure, options.stationIntervalM)}`; mark.dataset.structureId = id; diff --git a/resources/tester/helper_54_9_etc_pill.cjs b/resources/tester/helper_54_9_etc_pill.cjs index 4cb1a00d6..807b5d93f 100644 --- a/resources/tester/helper_54_9_etc_pill.cjs +++ b/resources/tester/helper_54_9_etc_pill.cjs @@ -34,6 +34,7 @@ function loadTs(file) { cache.set(full, box.exports); const localRequire = (request) => { if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".json")) return require(path.join(path.dirname(full), request)); if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} }); return require(request); diff --git a/resources/tester/test_54_9_etc_pill.py b/resources/tester/test_54_9_etc_pill.py index 0adec35b0..042849e80 100644 --- a/resources/tester/test_54_9_etc_pill.py +++ b/resources/tester/test_54_9_etc_pill.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- -"""54-9 종단 알약 글 — 기타가 여럿이어도 알약끼리 구별됨. +"""54-9 → 54-49 종단 알약 글 = 좌측 「종류」 칸 · 목록과 같은 이름. -• 손 이름 앞 3글자 · 같은 이름이면 목록과 같은 번호 · 빈 이름 = 「기타 N」 -• 머리가 같은 다른 이름은 갈릴 때까지 늘림 -• 다른 종류 알약(약호 + 중복 번호) · 툴팁 · 목록 이름(손 이름 그대로)은 그대로 +• 종류 이름(정의 이름) · 기타는 손 이름 · 같은 이름 여럿이면 목록과 같은 번호 · 빈 이름 = 「기타 N」 +• 약호 · 머리 자르기(옛 54-9 규칙) 안 씀 — 알약 = 툴팁 이름 화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_9_etc_pill.cjs`). B05 · B06 은 같은 알약 레인(`buildStructureLane`)을 씀 — 실제 화면은 ORCA 로 따로 본다. """ @@ -31,38 +30,31 @@ def out(): return json.loads(proc.stdout) -def test_53_2_기타_다섯_알약이_서로_다르다(out): +def test_53_2_기타_다섯_알약이_손_이름과_번호(out): pills = {key: value["pill"] for key, value in out["scenario"].items()} assert pills == { - "e1": "배수로 1", + "e1": "배수로정비 1", "e2": "울타리", "e3": "기타 1", - "e4": "배수로 2", + "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): + for group in out.values(): + for value in group.values(): + assert value["pill"] == value["name"] -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 = [value["pill"] for value in out["prefix"].values()] + assert pills == ["콘크리트 블록", "콘크리트 옹벽", "배수로", "배수로공", "기타 시설", "기타"] -def test_다른_종류_알약은_약호와_번호_그대로(out): +def test_다른_종류_알약은_종류_이름과_번호(out): pills = {key: value["pill"] for key, value in out["others"].items()} - assert pills == {"w1": "옹 1", "w2": "옹 2", "x1": "가로등", "x2": "기타"} + assert pills == {"w1": "옹벽 1", "w2": "옹벽 2", "x1": "가로등", "x2": "기타"} def test_알약_레인이_글을_한_곳에서_받는다(): diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 4861ceab8..48874fbb6 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -82,6 +82,9 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { const element = document.createElement("div"); element.className = `${blockClass}__context-menu ui-ctx-menu`; element.hidden = true; + // 메뉴 안 click 은 메뉴에서 끝냄 — 그릇(종단 그래프 등)의 「빈 곳 클릭 = 선택 해제」로 새면 + // 폼이 리셋되고 다시 그려져 메뉴가 사라짐(54-49). + element.addEventListener("click", (event) => event.stopPropagation()); const registryEntry = { close,