Files
goldenChart/frontend/src/utils/chartTimeFormat.ts
T
2026-05-29 02:25:03 +09:00

225 lines
7.3 KiB
TypeScript

/**
* 차트 하단 시간축·크로스헤어 시간 표시 포맷 (앱 설정 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<string, string> = {};
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);
}
}