Files
Aislo/ui_template/ui_template_progress.ts
T
eomsangdonandClaude Opus 5.5 acdd0aa469 feat(D 예정노선): 받기 진행 = 지도 귀퉁이 서클 · 끊김 = [다시 받기] · 겹 다시 받기 = 받은 범위 판정 · 보기 옮김 받지 않음 (PLAN 596-3 · 596-4 · 요구 463)
- 부품 progress 원에 corner 꼴(오른쪽 아래 · 글 왼쪽) · 부품 모음 견본
- 서버 다시 켜짐(끝 아닌데 멎음 · 진행 못 읽음) = 끊김 → [다시 받기]
- 겹: 받은 범위(have) 바뀌면 새로 받음 · 등고 · 세류 주소에 범위 지문 &v= · 등고 자르는 상자 = 받은 범위
- 임도망 [올리기] 지도 겹 아이콘 줄에서 왼쪽 칸 단추로(Layers 가 uploadRoads 내줌)
- Api: 받기 center · reset · 답 far · band 꼴 · 진행 interrupted · 임도망 bounds · distance_m(595 꼴)
- 시험 553_4: 막대 대신 귀퉁이 서클 단언

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:44:39 +09:00

161 lines
7.0 KiB
TypeScript

import "./ui_template_progress.css";
/* =============================================================================
* ui_template_progress.ts
* 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원.
* 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래)
* · 원 자리 = 흐름(기본) · 가운데 덮음(`overlay`) · 오른쪽 아래 귀퉁이(`corner` · 글은 원 왼쪽)
* 크기 = 막대 굵기 6 · 8(`thick` → data-size) · 원 지름 72(기본)
* 상태 = 기본 · 오류(`.is-error` · 막대가 위험색) · 판정(`tone`: warn · bad)
* 쓰는 법 = `createProgressBar({ value: 0.4, label: "40 %" })` · `bar.set(0.8)` · `createProgressBar({ parts: [{ value: 3, tone: "accent" }, { value: 1, tone: "warn" }] })`
* 대신하는 옛 변종 = B01 `.b01-proj-bar` · `.b01-pg-left__bar` · `.b01-pg-progress__bar` · `.b01-sys-gauge__bar` · `.b01-home__stack` · B03 `b03-file__progress-bar` · D03 날 progress · E04 · E07 `__progress`
*
* 공통 프로그레스 서클 (원형 진행 표시)
*
* 뷰포트 위에 얹어 "지금 무엇을 얼마나 불러왔는지"를 보여 주는 용도.
* 진행률을 모르는 구간은 set()에 ratio를 주지 않으면 회전 애니메이션으로 표시한다.
* 색상/치수는 theme.css 변수만 사용한다.
* ========================================================================== */
const SVG_NS = "http://www.w3.org/2000/svg";
/** viewBox 기준 반지름 — 실제 크기는 CSS(--ui-progress-size)로 정한다. */
const RADIUS = 42;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
export interface ProgressCircleOptions {
/** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */
size?: number;
/** 서클 아래 안내 문구. */
label?: string;
/** 컨테이너 정중앙에 띄우는 오버레이로 만든다(컨테이너는 position: relative 여야 한다). */
overlay?: boolean;
/** 컨테이너 오른쪽 아래 귀퉁이에 작게 띄운다(가운데를 안 가림 · 컨테이너는 position: relative). */
corner?: boolean;
}
export interface ProgressCircleHandle {
root: HTMLDivElement;
/** 진행률(0~1)과 문구 갱신. ratio가 null이면 진행률 미상(회전 표시). */
set: (ratio: number | null, label?: string) => void;
/** 화면에서 제거. */
remove: () => void;
}
export function createProgressCircle(options: ProgressCircleOptions = {}): ProgressCircleHandle {
const root = document.createElement("div");
root.className =
"ui-progress-circle" +
(options.overlay ? " ui-progress-circle--overlay" : "") +
(options.corner ? " ui-progress-circle--corner" : "");
root.setAttribute("role", "status");
root.setAttribute("aria-live", "polite");
if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`);
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("class", "ui-progress-circle__svg");
svg.setAttribute("viewBox", "0 0 100 100");
const track = document.createElementNS(SVG_NS, "circle");
track.setAttribute("class", "ui-progress-circle__track");
track.setAttribute("cx", "50");
track.setAttribute("cy", "50");
track.setAttribute("r", String(RADIUS));
const bar = document.createElementNS(SVG_NS, "circle");
bar.setAttribute("class", "ui-progress-circle__bar");
bar.setAttribute("cx", "50");
bar.setAttribute("cy", "50");
bar.setAttribute("r", String(RADIUS));
bar.setAttribute("stroke-dasharray", String(CIRCUMFERENCE));
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE));
svg.append(track, bar);
const percent = document.createElement("span");
percent.className = "ui-progress-circle__percent";
const label = document.createElement("span");
label.className = "ui-progress-circle__label";
label.textContent = options.label ?? "";
// 회전은 진행률과 **따로 논다**(2026-09-04 사용자 지시) — 무거운 단계에서 호가
// 안 늘어도 도넛은 계속 돌아야 "멈춘 것"으로 안 보인다. 바깥 껍데기만 CSS로 돌리고
// 안쪽 svg는 12시 고정이라, 호·숫자는 제자리에서 갱신된다.
const spin = document.createElement("div");
spin.className = "ui-progress-circle__spin";
spin.append(svg);
const dial = document.createElement("div");
dial.className = "ui-progress-circle__dial";
dial.append(spin, percent);
root.append(dial, label);
function set(ratio: number | null, nextLabel?: string): void {
if (nextLabel !== undefined) label.textContent = nextLabel;
if (ratio === null) {
// 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다).
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75));
percent.textContent = "";
return;
}
const clamped = Math.min(1, Math.max(0, ratio));
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped)));
percent.textContent = `${Math.round(clamped * 100)}%`;
}
set(0, options.label);
return {
root,
set,
remove: () => root.remove(),
};
}
export interface ProgressBarOptions {
/** 비율 0~1 — 쌓임 막대면 안 씀 */
value?: number;
/** 쌓임 막대 — 값은 서로의 비(합 = 막대 전체) */
parts?: { value: number; tone?: "accent" | "soft" | "warn" }[];
/** 굵기 px — 기본 8 */
thick?: 6 | 8;
tone?: "warn" | "bad";
/** 막대 오른쪽 글(「40 %」 · 「3 / 7」) */
label?: string;
}
export interface ProgressBarHandle {
root: HTMLDivElement;
/** 값 하나 막대 갱신 */
set: (value: number, label?: string) => void;
}
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
/** 진행 막대 — 값 하나 또는 쌓임 */
export function createProgressBar(opts: ProgressBarOptions = {}): ProgressBarHandle {
const root = document.createElement("div");
root.className = `ui-progress${opts.tone ? ` is-${opts.tone}` : ""}`;
root.dataset.size = String(opts.thick ?? 8);
const track = document.createElement("div");
track.className = "ui-progress__track";
root.setAttribute("role", "progressbar");
const text = document.createElement("span");
text.className = "ui-progress__label";
root.append(track, text);
const total = (opts.parts ?? []).reduce((a, p) => a + Math.max(0, p.value), 0);
for (const part of opts.parts ?? []) {
const seg = document.createElement("i");
seg.className = `ui-progress__fill ui-progress__fill--${part.tone ?? "accent"}`;
seg.style.width = `${total ? (Math.max(0, part.value) / total) * 100 : 0}%`;
track.append(seg);
}
const fill = document.createElement("i");
fill.className = "ui-progress__fill";
if (!opts.parts) track.append(fill);
const set = (value: number, label?: string): void => {
const v = clamp01(value);
fill.style.width = `${v * 100}%`;
root.setAttribute("aria-valuenow", String(Math.round(v * 100)));
if (label !== undefined) text.textContent = label;
};
set(opts.value ?? 0, opts.label ?? "");
return { root, set };
}