fix(structure): 종단 알약 글 = 종류 칸 이름 · 우클릭 메뉴 클릭이 그래프 선택 해제로 새지 않음(54-49)
- 알약 글 = 좌측 「종류」 칸 · 목록 · 정보 창 1행과 같은 이름(약호 · 기타 머리 자르기 뺌) · 같은 이름 여럿이면 번호 - 알약 겹침 판정 = 이름 글 폭 어림(고정 52px 뺌) - 공용 우클릭 메뉴 안 click 은 메뉴에서 멈춤 — 배관 고른 채 군 머리를 누르면 그래프 빈 곳 클릭으로 새어 폼 리셋 · 메뉴 사라짐 - 시험: test_54_9_etc_pill 새 규칙 · 헬퍼 json 불러오기 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
@@ -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<StructureInstance>,
|
||||
x: (chainageM: number) => number,
|
||||
labelOf: (structure: StructureInstance) => string,
|
||||
): Map<string, number | null> {
|
||||
const rows = new Map<string, number | null>();
|
||||
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<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). */
|
||||
/** 표시 이름 · 알약 글(구조물 id 별) — 둘 다 좌측 「종류」 칸 · 목록과 같은 이름(54-49).
|
||||
* 같은 이름이 여럿이면 시점에 가까운 순 「이름 1/2…」 · 기타 = 손 이름(빈 이름 = 「기타」). */
|
||||
export function structurePillTexts(
|
||||
structures: ReadonlyArray<StructureInstance>,
|
||||
typeMap: ReadonlyMap<string, StructureType>,
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_알약_레인이_글을_한_곳에서_받는다():
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user