/** * 차트 하단 시간축·크로스헤어 시간 표시 포맷 (앱 설정 chartTimeFormat). */ import { useSyncExternalStore } from 'react'; import { TickMarkType, type Time } from 'lightweight-charts'; import type { Timeframe } from '../types'; import { getDisplayTimezone, normalizeTimezone } from './timezone'; export const DEFAULT_CHART_TIME_FORMAT = 'MM-dd HH:mm'; export interface ChartTimeFormatPreset { id: string; label: string; } /** 통상적인 차트 시간축 프리셋 (일반설정·차트 설정 공통) */ export const CHART_TIME_FORMAT_PRESETS: ChartTimeFormatPreset[] = [ { id: 'yyyy-MM-dd HH:mm:ss', label: 'yyyy-MM-dd HH:mm:ss' }, { id: 'yyyy-MM-dd HH:mm', label: 'yyyy-MM-dd HH:mm' }, { id: 'yyyy-MM-dd', label: 'yyyy-MM-dd' }, { id: 'yyyy/MM/dd HH:mm:ss', label: 'yyyy/MM/dd HH:mm:ss' }, { id: 'yyyy/MM/dd HH:mm', label: 'yyyy/MM/dd HH:mm' }, { id: 'MM-dd HH:mm:ss', label: 'MM-dd HH:mm:ss' }, { id: 'MM-dd HH:mm', label: 'MM-dd HH:mm' }, { id: 'MM/dd/yyyy HH:mm:ss', label: 'MM/dd/yyyy HH:mm:ss' }, { id: 'MM/dd/yyyy HH:mm', label: 'MM/dd/yyyy HH:mm' }, { id: 'dd/MM/yyyy HH:mm:ss', label: 'dd/MM/yyyy HH:mm:ss' }, { id: 'dd/MM/yyyy HH:mm', label: 'dd/MM/yyyy HH:mm' }, { id: 'dd/MM HH:mm:ss', label: 'dd/MM HH:mm:ss' }, { id: 'dd/MM HH:mm', label: 'dd/MM HH:mm' }, { id: 'dd-MM HH:mm:ss', label: 'dd-MM HH:mm:ss' }, { id: 'dd-MM HH:mm', label: 'dd-MM HH:mm' }, { id: 'dd.MM.yyyy HH:mm:ss', label: 'dd.MM.yyyy HH:mm:ss' }, { id: 'dd.MM.yyyy HH:mm', label: 'dd.MM.yyyy HH:mm' }, { id: 'HH:mm:ss', label: 'HH:mm:ss' }, { id: 'HH:mm', label: 'HH:mm' }, ]; const PRESET_IDS = new Set(CHART_TIME_FORMAT_PRESETS.map(p => p.id)); let _format = DEFAULT_CHART_TIME_FORMAT; const _listeners = new Set<() => void>(); export function getChartTimeFormat(): string { return _format; } export function setChartTimeFormat(format: string): void { const next = normalizeChartTimeFormat(format); if (_format === next) return; _format = next; _listeners.forEach(l => l()); } export function subscribeChartTimeFormat(cb: () => void): () => void { _listeners.add(cb); return () => _listeners.delete(cb); } export function useChartTimeFormat(): string { return useSyncExternalStore( subscribeChartTimeFormat, getChartTimeFormat, () => DEFAULT_CHART_TIME_FORMAT, ); } /** 허용 토큰: yyyy yy MM dd HH mm ss */ export function normalizeChartTimeFormat(format: string): string { const t = (format || '').trim(); if (!t) return DEFAULT_CHART_TIME_FORMAT; if (!/[yMdHms]/.test(t)) return DEFAULT_CHART_TIME_FORMAT; return t.slice(0, 64); } export function isPresetChartTimeFormat(format: string): boolean { return PRESET_IDS.has(normalizeChartTimeFormat(format)); } interface ZonedParts { yyyy: string; yy: string; MM: string; dd: string; HH: string; mm: string; ss: string; } function getZonedParts(unixSec: number, tz: string): ZonedParts { const d = new Date(unixSec * 1000); const zone = normalizeTimezone(tz); const parts = new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).formatToParts(d); const map: Record = {}; for (const p of parts) { if (p.type !== 'literal') map[p.type] = p.value; } const year = map.year ?? ''; return { yyyy: year, yy: year.slice(-2), MM: map.month ?? '', dd: map.day ?? '', HH: map.hour ?? '', mm: map.minute ?? '', ss: map.second ?? '', }; } const TOKEN_ORDER = ['yyyy', 'yy', 'MM', 'dd', 'HH', 'mm', 'ss'] as const; export function applyChartTimePattern(pattern: string, parts: ZonedParts): string { let out = normalizeChartTimeFormat(pattern); for (const tok of TOKEN_ORDER) { out = out.split(tok).join(parts[tok]); } return out; } function patternHasDate(pattern: string): boolean { return /yyyy|yy|MM|dd/.test(pattern); } function patternHasTime(pattern: string): boolean { return /HH|mm|ss/.test(pattern); } /** 패턴에서 날짜·시간 부분 분리 (눈금 타입별 표시용) */ export function splitChartTimePattern(pattern: string): { date: string; time: string } { const p = normalizeChartTimeFormat(pattern); if (!patternHasTime(p)) return { date: p, time: '' }; if (!patternHasDate(p)) return { date: '', time: p }; const timeIdx = p.search(/HH|mm|ss/); if (timeIdx < 0) return { date: p, time: '' }; return { date: p.slice(0, timeIdx).trim(), time: p.slice(timeIdx).trim(), }; } function isDailyTimeframe(tf?: string): boolean { return tf === '1D' || tf === '1W' || tf === '1M'; } /** unix 초 → 사용자 지정 포맷 */ export function formatUnixWithChartPattern( unixSec: number, pattern?: string, tz?: string, ): string { if (!unixSec) return ''; const zone = normalizeTimezone(tz ?? getDisplayTimezone()); const parts = getZonedParts(unixSec, zone); return applyChartTimePattern(pattern ?? _format, parts); } /** LWC 크로스헤어·timeFormatter */ export function formatLwcTimeWithPattern( time: number | { year: number; month: number; day: number }, timeframe?: Timeframe, tz?: string, pattern?: string, ): string { const fmt = pattern ?? _format; const zone = normalizeTimezone(tz ?? getDisplayTimezone()); if (typeof time === 'number') { if (isDailyTimeframe(timeframe) && !patternHasTime(fmt)) { return formatUnixWithChartPattern(time, fmt, zone); } return formatUnixWithChartPattern(time, fmt, zone); } const unix = Math.floor(Date.UTC(time.year, time.month - 1, time.day) / 1000); return formatUnixWithChartPattern(unix, fmt, zone); } /** LWC X축 눈금 */ export function formatLwcTickMarkWithPattern( time: Time, tickMarkType: TickMarkType, timeframe?: Timeframe, tz?: string, pattern?: string, ): string { const fmt = normalizeChartTimeFormat(pattern ?? _format); const zone = normalizeTimezone(tz ?? getDisplayTimezone()); const unixSec = typeof time === 'number' ? time : time && typeof time === 'object' && 'year' in time ? Math.floor(Date.UTC(time.year, time.month - 1, time.day) / 1000) : null; if (unixSec == null) return ''; const parts = getZonedParts(unixSec, zone); const { date: datePat, time: timePat } = splitChartTimePattern(fmt); switch (tickMarkType) { case TickMarkType.Year: return parts.yyyy || applyChartTimePattern('yyyy', parts); case TickMarkType.Month: if (datePat.includes('MM')) return applyChartTimePattern(datePat.includes('yyyy') ? 'yyyy-MM' : 'MM', parts); return new Intl.DateTimeFormat('en-GB', { timeZone: zone, month: 'short' }).format(new Date(unixSec * 1000)); case TickMarkType.DayOfMonth: if (isDailyTimeframe(timeframe) || !patternHasTime(fmt)) { return datePat ? applyChartTimePattern(datePat, parts) : parts.dd; } return datePat ? applyChartTimePattern(datePat, parts) : parts.dd; case TickMarkType.Time: return timePat ? applyChartTimePattern(timePat.replace(/ss/g, ''), parts) : `${parts.HH}:${parts.mm}`; case TickMarkType.TimeWithSeconds: return timePat ? applyChartTimePattern(timePat, parts) : `${parts.HH}:${parts.mm}:${parts.ss}`; default: return formatUnixWithChartPattern(unixSec, fmt, zone); } }