도면을 열 때마다 0.8~1.0초가 걸렸다. 서버가 요청마다 도면을 새로 조립하고(0.3~0.6초), 구조물 모듈이 공유 캐시를 건너뛰고 종횡단 상세를 매번 다시 받았기 때문이다(0.2초). 진입 후 첫 장을 띄우고 나서 목록 전체를 배경에서 받아 캐시에 담고, 구조물 모듈은 B05·B06과 같은 Section_Store를 쓴다. 클릭에서 렌더까지 55~65ms로 줄었다. CAD 알림은 react-toastify라 프로젝트 공용 토스트와 모양이 달랐다. 호출부 30여 곳을 그대로 두려고 vite alias로 라이브러리 자리에 다리를 끼워, 부모가 있으면 메시지로 넘겨 부모 토스트로 띄운다. 백업 되살리기처럼 누르는 안내를 위해 showToast에 onClick을 더했다. 커서가 미묘하게 끌리던 것은 프레임이 느려서가 아니라(16.7ms, 60fps) 합성 파이프라인 때문이다. 화면 캔버스를 desynchronized+alpha:false로 만들어 그 큐를 건너뛴다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
918 lines
31 KiB
TypeScript
918 lines
31 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_elements.ts
|
|
* 공통 컴포넌트 및 그룹 템플릿 정의
|
|
*
|
|
* 원칙:
|
|
* - 모든 컴포넌트는 임의 스타일링 금지, 여기 규격을 최우선 상속/참조 (frontend.md §2).
|
|
* - 색상/간격은 CSS 클래스 + theme.css 변수로만 처리, JS 내 인라인 색상 금지.
|
|
* - 팩토리 함수는 HTMLElement를 반환하는 vanilla TS 패턴.
|
|
* - 스타일 규칙은 injectBaseStyles()로 1회 주입 (design.md 컴포넌트 명세 기반).
|
|
* ========================================================================== */
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 0. 내부 유틸
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
/** 요소 생성 + 속성/클래스/자식 일괄 설정 헬퍼 */
|
|
function el<K extends keyof HTMLElementTagNameMap>(
|
|
tag: K,
|
|
options: {
|
|
className?: string;
|
|
text?: string;
|
|
attrs?: Record<string, string>;
|
|
children?: (HTMLElement | string)[];
|
|
} = {},
|
|
): HTMLElementTagNameMap[K] {
|
|
const node = document.createElement(tag);
|
|
if (options.className) node.className = options.className;
|
|
if (options.text !== undefined) node.textContent = options.text;
|
|
if (options.attrs) {
|
|
for (const [k, v] of Object.entries(options.attrs)) {
|
|
node.setAttribute(k, v);
|
|
}
|
|
}
|
|
if (options.children) {
|
|
for (const child of options.children) {
|
|
node.append(child);
|
|
}
|
|
}
|
|
return node;
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 1. 버튼 (Button) — design.md: Filled Brand / Ghost Outlined / Pill Nav
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
export type ButtonVariant = "filled" | "ghost" | "pill" | "danger" | "glass";
|
|
|
|
export interface ButtonOptions {
|
|
/** 표시 텍스트 (i18n 결과 문자열을 전달) */
|
|
label: string;
|
|
variant?: ButtonVariant;
|
|
/** 이벤트 핸들러 (frontend.md §4: on[페이지]_[기능]_[액션] 명명) */
|
|
onClick?: (ev: MouseEvent) => void;
|
|
disabled?: boolean;
|
|
type?: "button" | "submit" | "reset";
|
|
/** 앞쪽 아이콘 요소 (선택) */
|
|
iconStart?: HTMLElement;
|
|
}
|
|
|
|
export function createButton(opts: ButtonOptions): HTMLButtonElement {
|
|
const variant = opts.variant ?? "filled";
|
|
const btn = el("button", {
|
|
className: `ui-btn ui-btn--${variant}`,
|
|
attrs: { type: opts.type ?? "button" },
|
|
});
|
|
if (opts.iconStart) {
|
|
opts.iconStart.classList.add("ui-btn__icon");
|
|
btn.append(opts.iconStart);
|
|
}
|
|
btn.append(el("span", { className: "ui-btn__label", text: opts.label }));
|
|
if (opts.disabled) btn.disabled = true;
|
|
if (opts.onClick) btn.addEventListener("click", opts.onClick);
|
|
return btn;
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 2. 입력 필드 (Input Field) — design.md: 1px mist border, 8px radius, focus ring
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
export interface InputFieldOptions {
|
|
/** 라벨 텍스트 (i18n 결과) */
|
|
label?: string;
|
|
/** placeholder (i18n 결과) */
|
|
placeholder?: string;
|
|
type?: "text" | "password" | "email" | "number" | "search" | "tel";
|
|
value?: string;
|
|
required?: boolean;
|
|
/** number 타입 범위 (1차 유효성 검사용, frontend.md §4) */
|
|
min?: number;
|
|
max?: number;
|
|
onInput?: (value: string) => void;
|
|
}
|
|
|
|
export interface InputFieldHandle {
|
|
/** 필드 컨테이너 (라벨 + 입력 + 에러 슬롯) */
|
|
root: HTMLDivElement;
|
|
input: HTMLInputElement;
|
|
/** 에러 메시지 표시/해제. msg가 없으면 에러 해제. */
|
|
setError: (msg?: string) => void;
|
|
}
|
|
|
|
export function createInputField(opts: InputFieldOptions): InputFieldHandle {
|
|
const root = el("div", { className: "ui-field" });
|
|
|
|
if (opts.label) {
|
|
root.append(el("label", { className: "ui-field__label", text: opts.label }));
|
|
}
|
|
|
|
const input = el("input", {
|
|
className: "ui-input",
|
|
attrs: {
|
|
type: opts.type ?? "text",
|
|
...(opts.placeholder ? { placeholder: opts.placeholder } : {}),
|
|
...(opts.required ? { required: "true" } : {}),
|
|
...(opts.min !== undefined ? { min: String(opts.min) } : {}),
|
|
...(opts.max !== undefined ? { max: String(opts.max) } : {}),
|
|
},
|
|
});
|
|
if (opts.value !== undefined) input.value = opts.value;
|
|
if (opts.onInput) {
|
|
input.addEventListener("input", () => opts.onInput!(input.value));
|
|
}
|
|
|
|
// 에러 메시지 슬롯 (입력창 하단 표기, frontend.md §4)
|
|
const errorSlot = el("span", { className: "ui-field__error", attrs: { role: "alert" } });
|
|
|
|
root.append(input, errorSlot);
|
|
|
|
const setError = (msg?: string): void => {
|
|
if (msg) {
|
|
input.classList.add("ui-input--error");
|
|
errorSlot.textContent = msg;
|
|
errorSlot.classList.add("is-visible");
|
|
} else {
|
|
input.classList.remove("ui-input--error");
|
|
errorSlot.textContent = "";
|
|
errorSlot.classList.remove("is-visible");
|
|
}
|
|
};
|
|
|
|
return { root, input, setError };
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 2-1. 드롭다운 필드 (Select Field) — design.md: input과 조화되는 8px radius, focus
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
export interface SelectFieldOptions {
|
|
/** 라벨 텍스트 */
|
|
label?: string;
|
|
/** 옵션 항목 목록 */
|
|
options: { value: string; text: string }[];
|
|
/** 초기 선택값 */
|
|
value?: string;
|
|
onChange?: (value: string) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export interface SelectFieldHandle {
|
|
root: HTMLDivElement;
|
|
select: HTMLSelectElement;
|
|
}
|
|
|
|
export function createSelectField(opts: SelectFieldOptions): SelectFieldHandle {
|
|
const root = el("div", { className: "ui-field" });
|
|
|
|
if (opts.label) {
|
|
root.append(el("label", { className: "ui-field__label", text: opts.label }));
|
|
}
|
|
|
|
const select = el("select", {
|
|
className: "ui-select",
|
|
});
|
|
|
|
for (const opt of opts.options) {
|
|
const optEl = el("option", {
|
|
attrs: { value: opt.value },
|
|
text: opt.text,
|
|
});
|
|
if (opts.value !== undefined && opt.value === opts.value) {
|
|
optEl.selected = true;
|
|
}
|
|
select.append(optEl);
|
|
}
|
|
|
|
if (opts.disabled) select.disabled = true;
|
|
|
|
if (opts.onChange) {
|
|
select.addEventListener("change", () => opts.onChange!(select.value));
|
|
}
|
|
|
|
root.append(select);
|
|
|
|
return { root, select };
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 3. 카드 / 패널 (Card) — design.md: white surface, 8px radius, shadow
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
export interface CardOptions {
|
|
title?: string;
|
|
/** 본문 자식 요소들 */
|
|
body?: (HTMLElement | string)[];
|
|
/** 그림자 강조 여부 (shadow-lg 사용) */
|
|
raised?: boolean;
|
|
}
|
|
|
|
export function createCard(opts: CardOptions): HTMLDivElement {
|
|
const card = el("div", {
|
|
className: `ui-card${opts.raised ? " ui-card--raised" : ""}`,
|
|
});
|
|
if (opts.title) {
|
|
card.append(el("h3", { className: "ui-card__title", text: opts.title }));
|
|
}
|
|
if (opts.body) {
|
|
card.append(el("div", { className: "ui-card__body", children: opts.body }));
|
|
}
|
|
return card;
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 4. 태그 / 배지 (Tag / Pill) — design.md: 1440px radius pill
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
export type TagVariant = "accent" | "neutral" | "success" | "warning" | "danger";
|
|
|
|
export function createTag(label: string, variant: TagVariant = "neutral"): HTMLSpanElement {
|
|
return el("span", { className: `ui-tag ui-tag--${variant}`, text: label });
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 5. 로딩 오버레이 (Loading Overlay) — frontend.md §4: API 호출 시 필수
|
|
* 싱글턴 오버레이. show/hide로 참조 카운트 관리.
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
let overlayEl: HTMLDivElement | null = null;
|
|
let overlayCount = 0;
|
|
|
|
function ensureOverlay(): HTMLDivElement {
|
|
if (overlayEl) return overlayEl;
|
|
const spinner = el("div", { className: "ui-spinner", attrs: { "aria-hidden": "true" } });
|
|
overlayEl = el("div", {
|
|
className: "ui-overlay",
|
|
attrs: { role: "status", "aria-live": "polite" },
|
|
children: [spinner],
|
|
});
|
|
document.body.append(overlayEl);
|
|
return overlayEl;
|
|
}
|
|
|
|
/** 로딩 스피너 오버레이 표시 (중첩 호출 안전) */
|
|
export function showLoadingOverlay(): void {
|
|
overlayCount += 1;
|
|
ensureOverlay().classList.add("is-active");
|
|
}
|
|
|
|
/** 로딩 스피너 오버레이 해제 (모든 참조 종료 시 실제 숨김) */
|
|
export function hideLoadingOverlay(): void {
|
|
overlayCount = Math.max(0, overlayCount - 1);
|
|
if (overlayCount === 0 && overlayEl) {
|
|
overlayEl.classList.remove("is-active");
|
|
}
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 6. 토스트 알림 (Toast) — 성공/에러 배너
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
export type ToastKind = "success" | "error" | "info" | "warning";
|
|
|
|
let toastContainer: HTMLDivElement | null = null;
|
|
|
|
function ensureToastContainer(): HTMLDivElement {
|
|
if (toastContainer) return toastContainer;
|
|
toastContainer = el("div", { className: "ui-toast-container" });
|
|
document.body.append(toastContainer);
|
|
return toastContainer;
|
|
}
|
|
|
|
/** 직전에 띄운 문구와 그 소멸 시각 — 같은 문구가 연달아 오면 무시한다. */
|
|
let lastToast: { key: string; until: number } | null = null;
|
|
|
|
/**
|
|
* 토스트 표시. durationMs 후 자동 소멸.
|
|
*
|
|
* **같은 문구 연속 무시**(2026-08-30 사용자 지시 2) — 한계 절삭 안내처럼 한 조작에서
|
|
* 여러 번 나오는 경고가 화면을 덮었다. 앞 토스트가 아직 떠 있는 동안 온 같은 문구는
|
|
* 버린다. 문구가 다르거나 앞 토스트가 사라진 뒤라면 정상적으로 다시 뜬다.
|
|
*/
|
|
export function showToast(
|
|
message: string,
|
|
kind: ToastKind = "info",
|
|
durationMs = 3000,
|
|
/** 누르면 실행할 동작 — 지정하면 토스트가 클릭 대상이 된다(CAD 백업 되살리기 등). */
|
|
onClick?: () => void,
|
|
): void {
|
|
const key = `${kind}::${message}`;
|
|
const now = Date.now();
|
|
if (lastToast && lastToast.key === key && now < lastToast.until) return;
|
|
lastToast = { key, until: now + durationMs };
|
|
const toast = el("div", { className: `ui-toast ui-toast--${kind}`, text: message });
|
|
if (onClick) {
|
|
toast.style.cursor = "pointer";
|
|
toast.addEventListener("click", () => {
|
|
onClick();
|
|
toast.remove();
|
|
});
|
|
}
|
|
ensureToastContainer().append(toast);
|
|
// 진입 애니메이션 트리거
|
|
requestAnimationFrame(() => toast.classList.add("is-visible"));
|
|
window.setTimeout(() => {
|
|
toast.classList.remove("is-visible");
|
|
window.setTimeout(() => toast.remove(), 200);
|
|
}, durationMs);
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 6-1. 확인 대화상자 (Confirm) — window.confirm 대체
|
|
*
|
|
* 공용 헤드 브라우저(playwright 연결)는 네이티브 confirm/alert를 자동 취소해
|
|
* 버려 [초기화] 같은 버튼이 죽은 것처럼 보였다(2026-08-19 진단). 화면 안 모달로
|
|
* 확인을 받아 어느 실행 환경에서도 같은 동작을 보장한다.
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
/** 확인/취소 모달 — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */
|
|
export function showConfirmDialog(message: string, confirmLabel = "확인"): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const overlay = el("div", { className: "ui-confirm" });
|
|
const panel = el("div", { className: "ui-confirm__panel" });
|
|
const text = el("p", { className: "ui-confirm__message", text: message });
|
|
const actions = el("div", { className: "ui-confirm__actions" });
|
|
const done = (result: boolean): void => {
|
|
overlay.remove();
|
|
resolve(result);
|
|
};
|
|
actions.append(
|
|
createButton({ label: "취소", variant: "ghost", onClick: () => done(false) }),
|
|
createButton({ label: confirmLabel, variant: "danger", onClick: () => done(true) }),
|
|
);
|
|
panel.append(text, actions);
|
|
overlay.append(panel);
|
|
document.body.append(overlay);
|
|
});
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 7. 워크플로우 3단 레이아웃 셸 (frontend.md §2)
|
|
* 상단 헤더 + 좌측 고정 패널 + 우측 뷰어 영역.
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
export interface WorkflowShellOptions {
|
|
/** 상단 페이지 타이틀 (i18n 결과) */
|
|
title: string;
|
|
}
|
|
|
|
export interface WorkflowShellHandle {
|
|
root: HTMLDivElement;
|
|
/** 좌측 입력 폼/설정 영역 (너비 고정) */
|
|
leftPanel: HTMLDivElement;
|
|
/** 우측 WebCAD 뷰어 / 결과 그리드 영역 */
|
|
rightArea: HTMLDivElement;
|
|
}
|
|
|
|
export function createWorkflowShell(opts: WorkflowShellOptions): WorkflowShellHandle {
|
|
// 상단 헤더
|
|
const titleEl = el("h2", { className: "ui-wf__title", text: opts.title });
|
|
const header = el("header", { className: "ui-wf__header", children: [titleEl] });
|
|
const leftPanel = el("div", { className: "ui-wf__left" });
|
|
const rightArea = el("div", { className: "ui-wf__right" });
|
|
const bodyRow = el("div", { className: "ui-wf__body", children: [leftPanel, rightArea] });
|
|
|
|
const root = el("div", { className: "ui-wf", children: [header, bodyRow] });
|
|
return { root, leftPanel, rightArea };
|
|
}
|
|
|
|
/* -----------------------------------------------------------------------------
|
|
* 7-1. 라인 차트 (Line Chart) — 시계열 데이터 SVG 렌더링
|
|
* 외부 라이브러리 없이 인라인 SVG. 색상은 CSS 클래스 + theme.css 변수 참조.
|
|
* -------------------------------------------------------------------------- */
|
|
|
|
export interface LineChartSeries {
|
|
/** 범례에 표시할 이름 (i18n 결과 문자열) */
|
|
name: string;
|
|
/** y 값 배열 (x는 인덱스 순서, null은 결측으로 선 끊김) */
|
|
values: (number | null)[];
|
|
/** 선 색상 클래스 접미사: 0~3 (theme.css의 --color-chart-N 참조) */
|
|
colorIndex?: 0 | 1 | 2 | 3;
|
|
}
|
|
|
|
export interface LineChartOptions {
|
|
series: LineChartSeries[];
|
|
/** x축 라벨 (values와 같은 길이 권장, 일부만 자동 선택 표기) */
|
|
xLabels?: string[];
|
|
/** y축 최대값 (기본: 100 = 퍼센트) */
|
|
yMax?: number;
|
|
/** y축 단위 접미사 (기본: "%") */
|
|
yUnit?: string;
|
|
/** 접근성 설명 */
|
|
ariaLabel?: string;
|
|
/** 커스텀 가상 가로폭 (기본: CHART_W = 640) */
|
|
width?: number;
|
|
/** 커스텀 가상 세로폭 (기본: CHART_H = 200) */
|
|
height?: number;
|
|
}
|
|
|
|
const CHART_W = 640;
|
|
const CHART_H = 200;
|
|
const CHART_PAD = { top: 12, right: 12, bottom: 26, left: 36 };
|
|
const X_TICK_STEP = 3; // x축 라벨 표기 간격 (3개마다 1개 표시)
|
|
|
|
/** 유효 점들을 Catmull-Rom → 3차 베지어로 변환한 스플라인 path 데이터를 만든다. */
|
|
function splinePath(points: { x: number; y: number }[]): string {
|
|
if (points.length === 0) return "";
|
|
if (points.length === 1) return `M${points[0].x},${points[0].y}`;
|
|
let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`;
|
|
for (let i = 0; i < points.length - 1; i += 1) {
|
|
const p0 = points[i - 1] ?? points[i];
|
|
const p1 = points[i];
|
|
const p2 = points[i + 1];
|
|
const p3 = points[i + 2] ?? p2;
|
|
// Catmull-Rom (tension 1/6) → cubic Bézier 제어점
|
|
const c1x = p1.x + (p2.x - p0.x) / 6;
|
|
const c1y = p1.y + (p2.y - p0.y) / 6;
|
|
const c2x = p2.x - (p3.x - p1.x) / 6;
|
|
const c2y = p2.y - (p3.y - p1.y) / 6;
|
|
d +=
|
|
` C${c1x.toFixed(1)},${c1y.toFixed(1)} ` +
|
|
`${c2x.toFixed(1)},${c2y.toFixed(1)} ` +
|
|
`${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/** 시계열 스플라인 차트를 반환. 데이터가 없으면 안내 문구를 담은 빈 상태를 반환. */
|
|
export function createLineChart(opts: LineChartOptions): HTMLDivElement {
|
|
const w = opts.width ?? CHART_W;
|
|
const h = opts.height ?? CHART_H;
|
|
const yMax = opts.yMax ?? 100;
|
|
const yUnit = opts.yUnit ?? "%";
|
|
const wrap = el("div", { className: "ui-chart" });
|
|
|
|
const pointCount = Math.max(0, ...opts.series.map((s) => s.values.length));
|
|
if (pointCount < 2) {
|
|
wrap.append(el("div", { className: "ui-chart__empty", text: "—" }));
|
|
wrap.setAttribute("data-empty", "true");
|
|
return wrap;
|
|
}
|
|
|
|
const plotW = w - CHART_PAD.left - CHART_PAD.right;
|
|
const plotH = h - CHART_PAD.top - CHART_PAD.bottom;
|
|
const xAt = (i: number) => CHART_PAD.left + (plotW * i) / (pointCount - 1);
|
|
const yAt = (v: number) => CHART_PAD.top + plotH * (1 - Math.min(v, yMax) / yMax);
|
|
|
|
const svgNs = "http://www.w3.org/2000/svg";
|
|
const svg = document.createElementNS(svgNs, "svg");
|
|
svg.setAttribute("class", "ui-chart__svg");
|
|
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
|
|
svg.setAttribute("role", "img");
|
|
svg.setAttribute("width", "100%");
|
|
svg.setAttribute("height", "100%");
|
|
svg.setAttribute("preserveAspectRatio", "none");
|
|
if (opts.ariaLabel) svg.setAttribute("aria-label", opts.ariaLabel);
|
|
|
|
// y축 그리드 + 라벨 (0, 25, 50, 75, 100%)
|
|
for (let g = 0; g <= 4; g += 1) {
|
|
const v = (yMax / 4) * g;
|
|
const y = yAt(v);
|
|
const line = document.createElementNS(svgNs, "line");
|
|
line.setAttribute("class", "ui-chart__grid");
|
|
line.setAttribute("x1", String(CHART_PAD.left));
|
|
line.setAttribute("x2", String(w - CHART_PAD.right));
|
|
line.setAttribute("y1", String(y));
|
|
line.setAttribute("y2", String(y));
|
|
svg.append(line);
|
|
const tick = document.createElementNS(svgNs, "text");
|
|
tick.setAttribute("class", "ui-chart__tick");
|
|
tick.setAttribute("x", String(CHART_PAD.left - 6));
|
|
tick.setAttribute("y", String(y + 4));
|
|
tick.setAttribute("text-anchor", "end");
|
|
tick.textContent = `${Math.round(v)}${yUnit}`;
|
|
svg.append(tick);
|
|
}
|
|
|
|
// x축 라벨 (데이터 포인트 개수만큼, X_TICK_STEP 간격으로 표기) + 수직 점선 그리드
|
|
if (opts.xLabels && opts.xLabels.length > 0) {
|
|
const labels = opts.xLabels;
|
|
const baseY = CHART_PAD.top + plotH;
|
|
for (let idx = 0; idx < pointCount; idx += X_TICK_STEP) {
|
|
const label = labels[idx];
|
|
const x = xAt(idx);
|
|
if (label !== undefined && label !== "") {
|
|
// 수직 점선 그리드
|
|
const vline = document.createElementNS(svgNs, "line");
|
|
vline.setAttribute("class", "ui-chart__grid ui-chart__grid--vertical");
|
|
vline.setAttribute("x1", String(x));
|
|
vline.setAttribute("x2", String(x));
|
|
vline.setAttribute("y1", String(CHART_PAD.top));
|
|
vline.setAttribute("y2", String(baseY));
|
|
svg.append(vline);
|
|
// x축 라벨
|
|
const tick = document.createElementNS(svgNs, "text");
|
|
tick.setAttribute("class", "ui-chart__tick ui-chart__tick--x");
|
|
tick.setAttribute("x", String(x));
|
|
tick.setAttribute("y", String(baseY + 16));
|
|
tick.setAttribute("text-anchor", "middle");
|
|
tick.textContent = label;
|
|
svg.append(tick);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 시리즈별 스플라인 (null 구간은 연속 세그먼트로 나눠 각각 곡선 처리)
|
|
for (const s of opts.series) {
|
|
let segment: { x: number; y: number }[] = [];
|
|
let d = "";
|
|
const flush = () => {
|
|
if (segment.length > 0) d += `${splinePath(segment)} `;
|
|
segment = [];
|
|
};
|
|
s.values.forEach((v, i) => {
|
|
if (v === null || v === undefined) {
|
|
flush();
|
|
return;
|
|
}
|
|
segment.push({ x: xAt(i), y: yAt(v) });
|
|
});
|
|
flush();
|
|
const path = document.createElementNS(svgNs, "path");
|
|
path.setAttribute("class", `ui-chart__line ui-chart__line--c${s.colorIndex ?? 0}`);
|
|
path.setAttribute("d", d.trim());
|
|
svg.append(path);
|
|
}
|
|
|
|
// 범례 (그래프 영역 우상단 오버레이)
|
|
const legend = el("div", { className: "ui-chart__legend" });
|
|
opts.series.forEach((s) => {
|
|
legend.append(
|
|
el("span", {
|
|
className: `ui-chart__legend-item ui-chart__legend-item--c${s.colorIndex ?? 0}`,
|
|
text: s.name,
|
|
}),
|
|
);
|
|
});
|
|
|
|
const plot = el("div", { className: "ui-chart__plot" });
|
|
plot.append(svg, legend);
|
|
wrap.append(plot);
|
|
return wrap;
|
|
}
|
|
|
|
/* =============================================================================
|
|
* 8. 기본 컴포넌트 스타일 주입 (injectBaseStyles)
|
|
* theme.css 변수만 참조. 앱 진입 시 1회 호출.
|
|
* ========================================================================== */
|
|
|
|
const BASE_STYLE_ID = "ui-template-elements-style";
|
|
|
|
const BASE_CSS = `
|
|
[hidden] { display: none !important; }
|
|
|
|
/* --- Button --- */
|
|
.ui-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: var(--spacing-8);
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-body-sm);
|
|
font-weight: var(--font-weight-medium);
|
|
line-height: 1;
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius-buttons);
|
|
padding: var(--spacing-8) var(--spacing-16);
|
|
cursor: pointer;
|
|
transition: background-color var(--transition-fast),
|
|
border-color var(--transition-fast), color var(--transition-fast);
|
|
}
|
|
.ui-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.ui-btn__icon { display: inline-flex; width: 16px; height: 16px; }
|
|
|
|
.ui-btn--filled {
|
|
background-color: var(--color-primary);
|
|
color: var(--color-primary-text);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
.ui-btn--filled:hover:not(:disabled) { background-color: var(--color-royal-amethyst); }
|
|
|
|
.ui-btn--ghost {
|
|
background-color: transparent;
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
.ui-btn--ghost:hover:not(:disabled) { background-color: var(--color-mist-violet); }
|
|
|
|
.ui-btn--pill {
|
|
background-color: transparent;
|
|
color: var(--color-plum-velvet);
|
|
border-radius: var(--radius-pills);
|
|
}
|
|
.ui-btn--pill:hover:not(:disabled),
|
|
.ui-btn--pill.is-active { background-color: var(--color-mist-violet); }
|
|
|
|
.ui-btn--danger {
|
|
background-color: var(--color-danger);
|
|
color: var(--color-canvas);
|
|
}
|
|
|
|
/* 3D 뷰포트 오버레이용: 배경 위에 떠 있어 반투명 + 블러 필요 */
|
|
.ui-btn--glass {
|
|
border-color: color-mix(in srgb, var(--color-border) 65%, transparent);
|
|
background-color: color-mix(in srgb, var(--color-surface-raised) 72%, transparent);
|
|
color: var(--color-text-body);
|
|
backdrop-filter: blur(6px);
|
|
-webkit-backdrop-filter: blur(6px);
|
|
}
|
|
.ui-btn--glass:hover:not(:disabled),
|
|
.ui-btn--glass.is-active {
|
|
border-color: var(--color-primary);
|
|
background-color: color-mix(in srgb, var(--color-primary) 82%, transparent);
|
|
color: var(--color-primary-text);
|
|
}
|
|
|
|
/* --- Input Field --- */
|
|
.ui-field { display: flex; flex-direction: column; gap: var(--spacing-4); }
|
|
.ui-field__label {
|
|
font-size: var(--text-caption);
|
|
font-weight: var(--font-weight-medium);
|
|
color: var(--color-text-secondary);
|
|
}
|
|
.ui-input {
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-body-sm);
|
|
color: var(--color-text-body);
|
|
background-color: var(--color-canvas);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-inputs);
|
|
padding: 10px 14px;
|
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
|
}
|
|
.ui-input::placeholder { color: var(--color-text-muted); }
|
|
.ui-input:focus {
|
|
outline: none;
|
|
border-color: var(--color-focus-ring);
|
|
box-shadow: 0 0 0 1px var(--color-focus-ring);
|
|
}
|
|
.ui-input--error { border-color: var(--color-danger); }
|
|
.ui-field__error {
|
|
font-size: var(--text-caption);
|
|
color: var(--color-danger);
|
|
min-height: 0;
|
|
visibility: hidden;
|
|
}
|
|
.ui-field__error.is-visible { visibility: visible; }
|
|
|
|
.ui-select {
|
|
appearance: none;
|
|
-webkit-appearance: none;
|
|
-moz-appearance: none;
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-body-sm);
|
|
color: var(--color-text-body);
|
|
background-color: var(--color-canvas);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-inputs);
|
|
padding: 10px 36px 10px 14px;
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%233e0079'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
|
|
background-repeat: no-repeat;
|
|
background-position: right 12px center;
|
|
background-size: 16px;
|
|
cursor: pointer;
|
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
|
}
|
|
.ui-select:focus {
|
|
outline: none;
|
|
border-color: var(--color-focus-ring);
|
|
box-shadow: 0 0 0 1px var(--color-focus-ring);
|
|
}
|
|
.ui-select:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* --- Card --- */
|
|
.ui-card {
|
|
background-color: var(--color-surface-raised);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-cards);
|
|
padding: var(--card-padding);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
.ui-card--raised { box-shadow: var(--shadow-lg); border-color: transparent; }
|
|
.ui-card__title {
|
|
font-family: var(--font-display);
|
|
color: var(--color-plum-velvet);
|
|
margin-bottom: var(--spacing-16);
|
|
}
|
|
.ui-card__body { display: flex; flex-direction: column; gap: var(--spacing-16); }
|
|
|
|
/* --- Tag / Pill --- */
|
|
.ui-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
border-radius: var(--radius-pills);
|
|
padding: var(--spacing-4) var(--spacing-16);
|
|
font-size: var(--text-caption);
|
|
font-weight: var(--font-weight-medium);
|
|
line-height: 1.3;
|
|
}
|
|
.ui-tag--accent { background-color: var(--color-mist-violet); color: var(--color-royal-amethyst); }
|
|
.ui-tag--neutral { background-color: var(--color-paper); color: var(--color-slate); }
|
|
.ui-tag--success { background-color: var(--color-mist-violet); color: var(--color-success); }
|
|
.ui-tag--warning { background-color: var(--color-paper); color: var(--color-warning); }
|
|
.ui-tag--danger { background-color: var(--color-paper); color: var(--color-danger); }
|
|
|
|
/* --- Loading Overlay + Spinner --- */
|
|
.ui-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background-color: rgba(38, 17, 74, 0.24);
|
|
z-index: var(--z-overlay);
|
|
}
|
|
.ui-overlay.is-active { display: flex; }
|
|
.ui-spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid var(--color-mist-violet);
|
|
border-top-color: var(--color-royal-amethyst);
|
|
border-radius: var(--radius-pills);
|
|
animation: ui-spin 0.8s linear infinite;
|
|
}
|
|
@keyframes ui-spin { to { transform: rotate(360deg); } }
|
|
|
|
/* --- Toast --- */
|
|
.ui-toast-container {
|
|
position: fixed;
|
|
top: var(--spacing-24);
|
|
right: var(--spacing-24);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--spacing-8);
|
|
z-index: var(--z-toast);
|
|
}
|
|
.ui-toast {
|
|
padding: var(--spacing-16) var(--spacing-24);
|
|
border-radius: var(--radius-cards);
|
|
font-size: var(--text-body-sm);
|
|
color: var(--color-canvas);
|
|
box-shadow: var(--shadow-lg);
|
|
opacity: 0;
|
|
transform: translateX(16px);
|
|
transition: opacity var(--transition-base), transform var(--transition-base);
|
|
}
|
|
.ui-toast.is-visible { opacity: 1; transform: translateX(0); }
|
|
.ui-toast--success { background-color: var(--color-success); }
|
|
.ui-toast--error { background-color: var(--color-danger); }
|
|
.ui-toast--warning { background-color: var(--color-warning); }
|
|
.ui-toast--info { background-color: var(--color-royal-amethyst); }
|
|
|
|
/* --- Confirm 모달 (window.confirm 대체 — 공용 브라우저 호환) --- */
|
|
.ui-confirm {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background-color: rgba(38, 17, 74, 0.35);
|
|
z-index: var(--z-overlay);
|
|
}
|
|
.ui-confirm__panel {
|
|
min-width: 300px;
|
|
max-width: 420px;
|
|
padding: var(--spacing-24);
|
|
border-radius: var(--radius-cards);
|
|
background-color: var(--color-surface-raised);
|
|
box-shadow: var(--shadow-lg);
|
|
}
|
|
.ui-confirm__message {
|
|
margin: 0 0 var(--spacing-16);
|
|
color: var(--color-text-body);
|
|
font-size: var(--text-body-sm);
|
|
white-space: pre-line;
|
|
}
|
|
.ui-confirm__actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: var(--spacing-8);
|
|
}
|
|
|
|
/* --- Workflow Shell (3단 레이아웃) --- */
|
|
.ui-wf { display: flex; flex-direction: column; height: 100%; }
|
|
.ui-wf__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
height: var(--wf-header-height);
|
|
padding: 0 var(--spacing-24);
|
|
border-bottom: 1px solid var(--color-border);
|
|
background-color: var(--color-surface-raised);
|
|
}
|
|
.ui-wf__title { font-size: var(--text-subheading); }
|
|
.ui-wf__body { display: flex; flex: 1; min-height: 0; }
|
|
.ui-wf__left {
|
|
width: var(--wf-left-panel-width);
|
|
flex-shrink: 0;
|
|
padding: var(--spacing-24);
|
|
border-right: 1px solid var(--color-border);
|
|
overflow-y: auto;
|
|
background-color: var(--color-surface);
|
|
}
|
|
.ui-wf__right {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: auto;
|
|
background-color: var(--color-bg);
|
|
}
|
|
|
|
/* --- Line Chart --- */
|
|
.ui-chart {
|
|
width: 100%;
|
|
}
|
|
.ui-chart__plot {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
.ui-chart__svg {
|
|
width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
}
|
|
.ui-chart__empty {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 120px;
|
|
color: var(--color-text-muted);
|
|
font-size: var(--text-body);
|
|
}
|
|
.ui-chart__grid {
|
|
stroke: var(--color-border);
|
|
stroke-width: 1;
|
|
opacity: 0.5;
|
|
}
|
|
.ui-chart__grid--vertical {
|
|
stroke-dasharray: 4, 4;
|
|
}
|
|
.ui-chart__tick {
|
|
fill: var(--color-text-muted);
|
|
font-size: 12px;
|
|
font-family: var(--font-body);
|
|
}
|
|
.ui-chart__tick--x {
|
|
font-size: 11px;
|
|
}
|
|
.ui-chart__line {
|
|
fill: none;
|
|
stroke-width: 2;
|
|
stroke-linejoin: round;
|
|
stroke-linecap: round;
|
|
}
|
|
.ui-chart__line--c0 { stroke: var(--color-chart-0); }
|
|
.ui-chart__line--c1 { stroke: var(--color-chart-1); }
|
|
.ui-chart__line--c2 { stroke: var(--color-chart-2); }
|
|
.ui-chart__line--c3 { stroke: var(--color-chart-3); }
|
|
.ui-chart__point {
|
|
stroke-width: 1.5;
|
|
stroke: var(--color-surface);
|
|
}
|
|
.ui-chart__point--c0 { fill: var(--color-chart-0); }
|
|
.ui-chart__point--c1 { fill: var(--color-chart-1); }
|
|
.ui-chart__point--c2 { fill: var(--color-chart-2); }
|
|
.ui-chart__point--c3 { fill: var(--color-chart-3); }
|
|
|
|
.ui-chart__legend {
|
|
position: absolute;
|
|
top: var(--spacing-4);
|
|
right: var(--spacing-8);
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: flex-end;
|
|
gap: var(--spacing-8) var(--spacing-16);
|
|
padding: var(--spacing-4) var(--spacing-8);
|
|
border-radius: var(--radius-cards);
|
|
background-color: color-mix(in srgb, var(--color-surface) 78%, transparent);
|
|
pointer-events: none;
|
|
}
|
|
.ui-chart__legend-item {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--spacing-8);
|
|
font-size: var(--text-caption);
|
|
color: var(--color-text);
|
|
}
|
|
.ui-chart__legend-item::before {
|
|
content: "";
|
|
width: 12px;
|
|
height: 3px;
|
|
border-radius: 2px;
|
|
background-color: currentColor;
|
|
}
|
|
.ui-chart__legend-item--c0 { color: var(--color-chart-0); }
|
|
.ui-chart__legend-item--c1 { color: var(--color-chart-1); }
|
|
.ui-chart__legend-item--c2 { color: var(--color-chart-2); }
|
|
.ui-chart__legend-item--c3 { color: var(--color-chart-3); }
|
|
`;
|
|
|
|
/** 공통 컴포넌트 기본 스타일을 <head>에 1회 주입. 앱 진입 시 호출. */
|
|
export function injectBaseStyles(): void {
|
|
if (document.getElementById(BASE_STYLE_ID)) return;
|
|
const style = el("style", { attrs: { id: BASE_STYLE_ID } });
|
|
style.textContent = BASE_CSS;
|
|
document.head.append(style);
|
|
}
|