From 41cfab22e9991a9334c0631e0373f546fd16e41c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 4 Sep 2026 11:19:04 +0900 Subject: [PATCH] =?UTF-8?q?refactor(ui=5Ftemplate):=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20700=EC=A4=84=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=EB=B6=84=EB=A6=AC=20=E2=80=94=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=C2=B7=EA=B8=B0=EB=B3=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 917줄 한 파일을 넷으로 나눔 (동작 불변, 순수 이동). - `ui_template_elements.ts` 363줄 — 버튼·입력·선택·카드·태그·토스트·확인창·셸 - `ui_template_elements_styles.ts` 369줄 — `injectBaseStyles()` 와 규칙 문자열 - `ui_template_elements_chart.ts` 185줄 — 공통 라인 차트 - `ui_template_elements_base.ts` 33줄 — 요소 생성 헬퍼 `el` (셋이 함께 써 순환 방지) 본체가 `export *` 로 다시 내보내 호출부의 import 경로는 불변. 검증: 분리 전 `export` 25개 전부 유지(+`el` 공개 1개 추가), `tsc --noEmit` 통과, tmp/tests 378 passed Co-Authored-By: Claude Opus 5 (1M context) --- ui_template/ui_template_elements.ts | 570 +-------------------- ui_template/ui_template_elements_base.ts | 33 ++ ui_template/ui_template_elements_chart.ts | 185 +++++++ ui_template/ui_template_elements_styles.ts | 370 +++++++++++++ 4 files changed, 596 insertions(+), 562 deletions(-) create mode 100644 ui_template/ui_template_elements_base.ts create mode 100644 ui_template/ui_template_elements_chart.ts create mode 100644 ui_template/ui_template_elements_styles.ts diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index d50e9d3b..9e74c56b 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -9,35 +9,16 @@ * - 스타일 규칙은 injectBaseStyles()로 1회 주입 (design.md 컴포넌트 명세 기반). * ========================================================================== */ -/* ----------------------------------------------------------------------------- - * 0. 내부 유틸 - * -------------------------------------------------------------------------- */ +import { el } from "./ui_template_elements_base"; + +// 차트·기본 스타일 조각은 파일이 700줄을 넘어 떼어냈다(2026-09-04). +// 여기서 그대로 다시 내보내 호출부의 import 경로는 불변이다. +export * from "./ui_template_elements_chart"; +export * from "./ui_template_elements_styles"; +export { el } from "./ui_template_elements_base"; /** 요소 생성 + 속성/클래스/자식 일괄 설정 헬퍼 */ -function el( - tag: K, - options: { - className?: string; - text?: string; - attrs?: Record; - 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 @@ -380,538 +361,3 @@ export function createWorkflowShell(opts: WorkflowShellOptions): WorkflowShellHa * 외부 라이브러리 없이 인라인 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); } -`; - -/** 공통 컴포넌트 기본 스타일을 에 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); -} diff --git a/ui_template/ui_template_elements_base.ts b/ui_template/ui_template_elements_base.ts new file mode 100644 index 00000000..a5064e04 --- /dev/null +++ b/ui_template/ui_template_elements_base.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * ui_template_elements_base.ts + * 공통 컴포넌트의 내부 유틸 — 요소 생성 헬퍼. + * + * `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 차트·스타일 + * 조각과 본체가 함께 쓰므로 순환 임포트를 피하려고 맨 아래층에 둔다. + * ========================================================================== */ + +/** 요소 생성 + 속성/클래스/자식 일괄 설정 헬퍼 */ +export function el( + tag: K, + options: { + className?: string; + text?: string; + attrs?: Record; + 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; +} diff --git a/ui_template/ui_template_elements_chart.ts b/ui_template/ui_template_elements_chart.ts new file mode 100644 index 00000000..0685caa4 --- /dev/null +++ b/ui_template/ui_template_elements_chart.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * ui_template_elements_chart.ts + * 공통 라인 차트 — 대시보드 리소스 그래프에 쓰는 SVG 꺾은선. + * + * `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 본체가 그대로 + * 다시 내보내므로 호출부의 import 경로는 바뀌지 않는다. + * ========================================================================== */ + +import { el } from "./ui_template_elements_base"; + +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회 호출. + * ========================================================================== */ + diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts new file mode 100644 index 00000000..99454304 --- /dev/null +++ b/ui_template/ui_template_elements_styles.ts @@ -0,0 +1,370 @@ +/* ============================================================================= + * ui_template_elements_styles.ts + * 공통 컴포넌트 기본 스타일 규칙 — injectBaseStyles() 로 1회 주입한다. + * + * `ui_template_elements.ts` 가 700줄을 넘어 떼어냈다(2026-09-04). 규칙 문자열은 + * 그대로이고, 본체가 다시 내보내므로 호출부는 바뀌지 않는다. + * ========================================================================== */ + +import { el } from "./ui_template_elements_base"; + +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); } +`; + +/** 공통 컴포넌트 기본 스타일을 에 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); +}