225 lines
7.3 KiB
TypeScript
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);
|
|
}
|
|
}
|