무슨 일이 있었나 랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운 커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`· `CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴. 잃었던 것 - 공용 — `common_util_provenance.py` · `ui_template_provenance.ts` - B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸 - B09 — 근거 사전 셋 - B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈 - 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등) 어떻게 되살렸나 `611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림. 이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난 옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛 상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 — `9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑). 위키 여덟은 코덱스 몫이라 손대지 않음. 자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 · `B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의 `attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped** (되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
288 lines
13 KiB
TypeScript
288 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_provenance.ts
|
|
* 표 칸에 마우스를 올리면 **그 숫자가 어디서 와서 어떻게 나왔는지**를 띄우는 한 벌.
|
|
* B08 수량·B09 원가가 같이 쓴다 (PLAN 8-36 ②③).
|
|
*
|
|
* ⚠⚠ **개발 전용 — 사용자에게 보이지 않는다 (로직 보안).**
|
|
* 사전은 서버가 개발환경에서만 실어 보낸다(`common_util_provenance.provenance_payload`).
|
|
* 사전이 안 오면 이 모듈은 **아무것도 하지 않는다** — 화면에서 숨기는 것이 아니라
|
|
* 애초에 들고 있지 않은 것이 요점이다. 그래서 낱말도 번역하지 않는다(안 나간다).
|
|
*
|
|
* ⚠ 겉보기는 **칸 단위**지만 사전은 **열 단위**다. 칸마다 사전을 만들면 토적표 한 장이
|
|
* 6천 칸이라 응답이 붐는다. 줄마다 갈리는 것(폴백 사유 등)은 부르는 쪽이
|
|
* `resolveExtra` 로 얹는다.
|
|
* ========================================================================== */
|
|
|
|
/** 열 하나의 사전. 서버 `ColumnProvenance.as_dict()` 와 1:1. */
|
|
export interface ProvenanceColumn {
|
|
label: string;
|
|
tier: string;
|
|
formula?: string;
|
|
source?: string;
|
|
/** 고르는 자리의 채택 규칙(「안전관리비 A·B 중 작은 쪽」 따위). 없으면 칸이 안 뜨다. */
|
|
rule?: string;
|
|
code?: string;
|
|
}
|
|
|
|
/** 한 장(시트)의 사전. */
|
|
export interface ProvenanceSheet {
|
|
columns: Record<string, ProvenanceColumn>;
|
|
}
|
|
|
|
/** 응답에 실려 오는 사전 전체. 개발환경이 아니면 **칸 자체가 없다**(`undefined`). */
|
|
export interface ProvenancePayload {
|
|
sheets: Record<string, ProvenanceSheet>;
|
|
}
|
|
|
|
/** 등급 여섯(+미분류). 키는 서버와 같은 낱말이라야 한다 — 어긋나면 색도 카드도 빈다. */
|
|
const TIER_LABELS: Record<string, string> = {
|
|
input: "입력",
|
|
survey: "측량",
|
|
standard: "기준",
|
|
calc: "계산",
|
|
final: "최종",
|
|
blocked: "막힘",
|
|
excluded: "제외",
|
|
unclassified: "미분류",
|
|
};
|
|
|
|
/** 색칠을 켤지 — **포트별로 갈리는 sessionStorage** 에 둔다(창마다 취향이 다르다). */
|
|
const TINT_KEY = "aislo.provenance.tint";
|
|
|
|
const STYLE_ID = "ui-provenance-style";
|
|
const CARD_ID = "ui-provenance-card";
|
|
|
|
/**
|
|
* 칸에 심는 표시 — 열 키와 등급. 표를 그리는 쪽이 칸마다 한 번 부른다.
|
|
*
|
|
* `tier` 를 주면 **그 칸만 열 등급을 이긴다.** 같은 열이라도 줄마다 성격이 갈리는
|
|
* 자리가 있기 때문이다 — 원가계산서 「금액」은 중간줄(간접노무비 따위)이 `calc` 인데
|
|
* 마지막줄(총원가·도급금액·총계)은 `final` 이다(2026-09-12 데스크탑 보조 B09 배선).
|
|
* 칸 등급을 심지 않으면 열 등급이 그대로 선다.
|
|
*/
|
|
export function markProvenanceCell(cell: HTMLElement, columnKey: string, tier?: string): void {
|
|
cell.dataset.provCol = columnKey;
|
|
if (tier) cell.dataset.provTier = tier;
|
|
}
|
|
|
|
export function isProvenanceTinted(): boolean {
|
|
try {
|
|
return sessionStorage.getItem(TINT_KEY) === "on";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function setTinted(root: HTMLElement, on: boolean): void {
|
|
root.classList.toggle("is-prov-tinted", on);
|
|
try {
|
|
sessionStorage.setItem(TINT_KEY, on ? "on" : "off");
|
|
} catch {
|
|
/* 저장이 막힌 창에서도 화면은 돌아야 한다 — 이번 화면에서만 켜진다. */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 색칠 토글 단추. **사전이 없으면 만들지 않는다**(부르는 쪽이 `payload` 를 보고 거른다).
|
|
*
|
|
* ⚠ 여섯 색이 늘 켜져 있으면 표가 알록달록해 실무 시트와 눈으로 대조를 못 한다.
|
|
* 그래서 **평소엔 꺼 두고** 이 단추로만 켠다.
|
|
*/
|
|
export function createProvenanceToggle(root: HTMLElement): HTMLElement {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "ui-prov-toggle";
|
|
const paint = (): void => {
|
|
const on = root.classList.contains("is-prov-tinted");
|
|
button.textContent = on ? "등급색 끄기" : "등급색 켜기";
|
|
button.classList.toggle("is-on", on);
|
|
};
|
|
setTinted(root, isProvenanceTinted());
|
|
paint();
|
|
button.addEventListener("click", () => {
|
|
setTinted(root, !root.classList.contains("is-prov-tinted"));
|
|
paint();
|
|
});
|
|
return button;
|
|
}
|
|
|
|
function line(card: HTMLElement, name: string, value: string): void {
|
|
if (!value) return;
|
|
const row = document.createElement("div");
|
|
row.className = "ui-prov-card__row";
|
|
const key = document.createElement("span");
|
|
key.className = "ui-prov-card__key";
|
|
key.textContent = name;
|
|
const body = document.createElement("span");
|
|
body.className = "ui-prov-card__value";
|
|
body.textContent = value;
|
|
row.append(key, body);
|
|
card.append(row);
|
|
}
|
|
|
|
function card(): HTMLElement {
|
|
let element = document.getElementById(CARD_ID);
|
|
if (!element) {
|
|
element = document.createElement("div");
|
|
element.id = CARD_ID;
|
|
element.className = "ui-prov-card";
|
|
document.body.append(element);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
/** 카드를 마우스 옆에 둔다 — 화면 밖으로 나가면 반대쪽으로 접는다. */
|
|
function place(element: HTMLElement, x: number, y: number): void {
|
|
element.style.visibility = "hidden";
|
|
element.style.display = "block";
|
|
const box = element.getBoundingClientRect();
|
|
const left = x + 16 + box.width > window.innerWidth ? x - 16 - box.width : x + 16;
|
|
const top = y + 16 + box.height > window.innerHeight ? y - 16 - box.height : y + 16;
|
|
element.style.left = `${Math.max(4, left)}px`;
|
|
element.style.top = `${Math.max(4, top)}px`;
|
|
element.style.visibility = "visible";
|
|
}
|
|
|
|
/** 카드 한 장을 채운다. 값은 **화면에 적힌 글자 그대로** 보인다 — 자리수까지 같은 것이 요점. */
|
|
/** 카드 한 장을 채운다. 배지는 **칸 등급**을 먼저 본다 — 띄는 띄었는데 배지가 다른 말을
|
|
* 하면 읽는 사람이 둘 중 어느 것을 믿을지 모른다. */
|
|
function fill(
|
|
target: HTMLElement,
|
|
cell: HTMLElement,
|
|
column: ProvenanceColumn,
|
|
value: string,
|
|
extra: string[],
|
|
): void {
|
|
target.replaceChildren();
|
|
const head = document.createElement("div");
|
|
head.className = "ui-prov-card__head";
|
|
const title = document.createElement("span");
|
|
title.textContent = column.label;
|
|
const badge = document.createElement("span");
|
|
badge.className = "ui-prov-card__badge";
|
|
const tier = cell.dataset.provTier || column.tier;
|
|
badge.dataset.provTier = tier;
|
|
badge.textContent = TIER_LABELS[tier] ?? tier;
|
|
head.append(title, badge);
|
|
target.append(head);
|
|
|
|
line(target, "값", value);
|
|
line(target, "식", column.formula ?? "");
|
|
line(target, "원천", column.source ?? "");
|
|
line(target, "채택", column.rule ?? "");
|
|
line(target, "자리", column.code ?? "");
|
|
for (const note of extra) line(target, "줄 사유", note);
|
|
}
|
|
|
|
/**
|
|
* 표에 호버를 붙인다. 칸에 심어 둔 `data-prov-col` 로 사전을 찾는다.
|
|
*
|
|
* `resolveExtra` — 줄마다 갈리는 사유(폴백 안분 등)를 얹고 싶을 때 부르는 쪽이 준다.
|
|
* 사전에 없는 열은 **아무 일도 안 한다** — 빈 카드를 띄우면 「설명이 있다」는 거짓이 남는다.
|
|
*/
|
|
export function attachProvenance(
|
|
root: HTMLElement,
|
|
sheet: ProvenanceSheet | undefined,
|
|
resolveExtra?: (cell: HTMLElement, columnKey: string) => string[],
|
|
): void {
|
|
if (!sheet) return;
|
|
injectProvenanceStyles();
|
|
// 등급을 안 심은 칸은 **여기서 열 등급으로 채운다.**
|
|
// 색칠은 CSS 가 `data-prov-tier` 를 보고 하므로, 그 칸은 배지만 띄고 띄는 안 붙어
|
|
// 「색이 안 붙는 칸」이 생긴다. 부르는 쪽이 등급을 빼먹는 것은 흔한 일이라 여기서 맞춘다.
|
|
for (const cell of root.querySelectorAll<HTMLElement>("[data-prov-col]")) {
|
|
if (cell.dataset.provTier) continue;
|
|
const tier = sheet.columns[cell.dataset.provCol ?? ""]?.tier;
|
|
if (tier) cell.dataset.provTier = tier;
|
|
}
|
|
const hide = (): void => {
|
|
const element = document.getElementById(CARD_ID);
|
|
if (element) element.style.display = "none";
|
|
};
|
|
root.addEventListener("mouseover", (event) => {
|
|
const cell = (event.target as HTMLElement).closest<HTMLElement>("[data-prov-col]");
|
|
if (!cell || !root.contains(cell)) return;
|
|
const column = sheet.columns[cell.dataset.provCol ?? ""];
|
|
if (!column) return hide();
|
|
const target = card();
|
|
fill(
|
|
target,
|
|
cell,
|
|
column,
|
|
cell.textContent?.trim() ?? "",
|
|
resolveExtra?.(cell, cell.dataset.provCol ?? "") ?? [],
|
|
);
|
|
place(target, (event as MouseEvent).clientX, (event as MouseEvent).clientY);
|
|
});
|
|
root.addEventListener("mousemove", (event) => {
|
|
const element = document.getElementById(CARD_ID);
|
|
if (!element || element.style.display === "none") return;
|
|
place(element, (event as MouseEvent).clientX, (event as MouseEvent).clientY);
|
|
});
|
|
root.addEventListener("mouseleave", hide);
|
|
root.addEventListener("mouseout", (event) => {
|
|
const next = (event as MouseEvent).relatedTarget as HTMLElement | null;
|
|
if (!next || !next.closest?.("[data-prov-col]")) hide();
|
|
});
|
|
}
|
|
|
|
const CSS = `
|
|
/* 등급색 — 평소엔 꺼져 있고 토글로만 켠다. 칸 왼쪽 얇은 띠 + 아주 옅은 배경이라
|
|
숫자 읽기를 방해하지 않는다. 색은 테마 변수를 섞어 어두운 테마에서도 맞는다. */
|
|
.is-prov-tinted [data-prov-tier="input"] { box-shadow: inset 3px 0 0 var(--color-accent); background: color-mix(in srgb, var(--color-accent) 7%, transparent); }
|
|
.is-prov-tinted [data-prov-tier="survey"] { box-shadow: inset 3px 0 0 var(--color-info, #3b82f6); background: color-mix(in srgb, var(--color-info, #3b82f6) 7%, transparent); }
|
|
.is-prov-tinted [data-prov-tier="standard"] { box-shadow: inset 3px 0 0 var(--color-text-secondary); background: color-mix(in srgb, var(--color-text-secondary) 7%, transparent); }
|
|
.is-prov-tinted [data-prov-tier="calc"] { box-shadow: inset 3px 0 0 var(--color-success, #16a34a); background: color-mix(in srgb, var(--color-success, #16a34a) 7%, transparent); }
|
|
.is-prov-tinted [data-prov-tier="final"] { box-shadow: inset 3px 0 0 var(--color-warning, #c08a3e); background: color-mix(in srgb, var(--color-warning, #c08a3e) 10%, transparent); }
|
|
.is-prov-tinted [data-prov-tier="blocked"] { box-shadow: inset 3px 0 0 var(--color-danger, #dc2626); background: color-mix(in srgb, var(--color-danger, #dc2626) 8%, transparent); }
|
|
.is-prov-tinted [data-prov-tier="excluded"] { box-shadow: inset 3px 0 0 var(--color-muted, #9ca3af); background: repeating-linear-gradient(135deg, transparent, transparent 5px, color-mix(in srgb, var(--color-muted, #9ca3af) 12%, transparent) 5px, color-mix(in srgb, var(--color-muted, #9ca3af) 12%, transparent) 10px); }
|
|
.is-prov-tinted [data-prov-tier="unclassified"] { box-shadow: inset 3px 0 0 var(--color-border); }
|
|
|
|
.ui-prov-toggle {
|
|
font-size: 11px;
|
|
padding: 2px 8px;
|
|
color: var(--color-text-secondary);
|
|
background: var(--color-surface);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-pills, 999px);
|
|
cursor: pointer;
|
|
}
|
|
.ui-prov-toggle.is-on { color: var(--color-accent); border-color: var(--color-accent); }
|
|
|
|
.ui-prov-card {
|
|
display: none;
|
|
position: fixed;
|
|
z-index: 9999;
|
|
max-width: 26rem;
|
|
padding: 8px 10px;
|
|
font-size: 11.5px;
|
|
line-height: 1.5;
|
|
color: var(--color-text);
|
|
background: var(--color-surface-raised);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-cards, 8px);
|
|
box-shadow: 0 6px 18px rgb(0 0 0 / 18%);
|
|
pointer-events: none;
|
|
}
|
|
.ui-prov-card__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; font-weight: 600; }
|
|
.ui-prov-card__badge { flex: 0 0 auto; padding: 0 6px; font-size: 10.5px; font-weight: 500; border-radius: var(--radius-pills, 999px); border: 1px solid currentColor; }
|
|
.ui-prov-card__badge[data-prov-tier="input"] { color: var(--color-accent); }
|
|
.ui-prov-card__badge[data-prov-tier="survey"] { color: var(--color-info, #3b82f6); }
|
|
.ui-prov-card__badge[data-prov-tier="standard"] { color: var(--color-text-secondary); }
|
|
.ui-prov-card__badge[data-prov-tier="calc"] { color: var(--color-success, #16a34a); }
|
|
.ui-prov-card__badge[data-prov-tier="final"] { color: var(--color-warning, #c08a3e); }
|
|
.ui-prov-card__badge[data-prov-tier="blocked"] { color: var(--color-danger, #dc2626); }
|
|
.ui-prov-card__badge[data-prov-tier="excluded"] { color: var(--color-muted, #9ca3af); }
|
|
.ui-prov-card__row { display: flex; gap: 8px; }
|
|
.ui-prov-card__key { flex: 0 0 2.4rem; color: var(--color-text-secondary); }
|
|
.ui-prov-card__value { min-width: 0; white-space: pre-wrap; }
|
|
`;
|
|
|
|
export function injectProvenanceStyles(): void {
|
|
if (document.getElementById(STYLE_ID)) return;
|
|
const style = document.createElement("style");
|
|
style.id = STYLE_ID;
|
|
style.textContent = CSS;
|
|
document.head.append(style);
|
|
}
|