위젯 기능 추가

This commit is contained in:
Macbook
2026-06-14 01:03:51 +09:00
parent 95595f7e9e
commit 6936792ad4
57 changed files with 6728 additions and 540 deletions
+80
View File
@@ -0,0 +1,80 @@
/** 플로팅(팝업) 위젯 창 — 메뉴바에서 다중 실행 */
import { newWidgetId, newWidgetSlot, type WidgetSlot } from './widgetDashboard';
export interface FloatingWidgetLayoutPreset {
id: string;
rows: number;
cols: number;
label: string;
}
export const FLOATING_WIDGET_LAYOUTS: FloatingWidgetLayoutPreset[] = [
{ id: '1x1', rows: 1, cols: 1, label: '1×1' },
{ id: '1x2', rows: 1, cols: 2, label: '1×2' },
{ id: '2x1', rows: 2, cols: 1, label: '2×1' },
{ id: '2x2', rows: 2, cols: 2, label: '2×2' },
{ id: '1x3', rows: 1, cols: 3, label: '1×3' },
{ id: '3x1', rows: 3, cols: 1, label: '3×1' },
{ id: '2x3', rows: 2, cols: 3, label: '2×3' },
{ id: '3x2', rows: 3, cols: 2, label: '3×2' },
{ id: '3x3', rows: 3, cols: 3, label: '3×3' },
];
export interface FloatingWidgetInstance {
id: string;
rows: number;
cols: number;
slots: WidgetSlot[];
position: { x: number; y: number };
width: number;
height: number;
zIndex: number;
}
export function defaultSizeForGrid(rows: number, cols: number): { width: number; height: number } {
const cellW = 280;
const cellH = 220;
const chromeW = 16;
const chromeH = 44;
return {
width: Math.min(cols * cellW + chromeW, window.innerWidth - 40),
height: Math.min(rows * cellH + chromeH, window.innerHeight - 80),
};
}
/** 그리드 칸당 최소 크기 — 리사이즈 하한 */
export function minSizeForGrid(rows: number, cols: number): { width: number; height: number } {
const cellMinW = 140;
const cellMinH = 100;
const chromeW = 16;
const chromeH = 44;
return {
width: cols * cellMinW + chromeW,
height: rows * cellMinH + chromeH,
};
}
export function createFloatingWidgetInstance(
rows: number,
cols: number,
index: number,
baseZIndex: number,
): FloatingWidgetInstance {
const count = rows * cols;
const { width, height } = defaultSizeForGrid(rows, cols);
const offset = 28 * index;
return {
id: newWidgetId('fw'),
rows,
cols,
slots: Array.from({ length: count }, () => newWidgetSlot()),
position: {
x: Math.min(80 + offset, Math.max(24, window.innerWidth - width - 24)),
y: Math.min(72 + offset, Math.max(24, window.innerHeight - height - 24)),
},
width,
height,
zIndex: baseZIndex + index,
};
}
+3
View File
@@ -5,6 +5,7 @@ import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } fro
import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage';
import type { UserStrategyTemplate } from '../utils/strategyTemplateStorage';
import type { SidewaysFilterPaletteEntry } from '../utils/sidewaysFilterPaletteStorage';
import type { WidgetLayoutSchema } from './widgetDashboard';
/** gc_app_settings.ui_preferences_json 구조 */
export interface UiPreferences {
@@ -54,6 +55,8 @@ export interface UiPreferences {
/** 시간봉 필터 — 빈 문자열=전체, 그 외 candleType (1m, 5m, …) */
candleFilter?: string;
};
/** 위젯 대시보드 — 단일 레이아웃 (기기 간 동기화) */
widgetDashboard?: WidgetLayoutSchema;
}
export const UI_PREFERENCES_VERSION = 1;
+166
View File
@@ -0,0 +1,166 @@
/** 위젯 대시보드 레이아웃 스키마 (uiPreferences.widgetDashboard) */
export type WidgetZoneId = 'left' | 'center' | 'right' | 'bottom';
export type WidgetZoneMode = 'single' | 'split' | 'tabs';
export type WidgetCategory = 'center' | 'side';
export interface WidgetSlot {
id: string;
widgetType: string | null;
config?: Record<string, unknown>;
}
export interface WidgetTabConfig {
id: string;
label: string;
/** 탭 내부 split 슬롯 (1~3) */
splitCount: 1 | 2 | 3;
slots: WidgetSlot[];
}
export interface WidgetZoneConfig {
mode: WidgetZoneMode;
/** split 모드 — 상하 카드 분할 수 */
splitCount?: 1 | 2 | 3;
slots?: WidgetSlot[];
tabs?: WidgetTabConfig[];
}
export interface WidgetLayoutSchema {
v: 1;
zones: {
left: WidgetZoneConfig | null;
center: WidgetZoneConfig;
right: WidgetZoneConfig | null;
bottom: WidgetZoneConfig | null;
};
collapsed: {
left?: boolean;
right?: boolean;
bottom?: boolean;
};
panelSizes: {
leftWidth?: number;
rightWidth?: number;
footerHeight?: number;
};
}
export function newWidgetId(prefix = 'w'): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
export function newWidgetSlot(widgetType: string | null = null): WidgetSlot {
return { id: newWidgetId('slot'), widgetType, config: {} };
}
export function slotsForSplitCount(count: 1 | 2 | 3, defaults: (string | null)[]): WidgetSlot[] {
return Array.from({ length: count }, (_, i) =>
newWidgetSlot(defaults[i] ?? null),
);
}
export function newWidgetTab(label: string, splitCount: 1 | 2 | 3 = 1, defaults: (string | null)[] = []): WidgetTabConfig {
return {
id: newWidgetId('tab'),
label,
splitCount,
slots: slotsForSplitCount(splitCount, defaults),
};
}
export function defaultWidgetLayout(): WidgetLayoutSchema {
return {
v: 1,
zones: {
left: {
mode: 'single',
splitCount: 1,
slots: [newWidgetSlot('market.list')],
},
center: {
mode: 'single',
splitCount: 1,
slots: [newWidgetSlot('chart.candle')],
},
right: {
mode: 'split',
splitCount: 2,
slots: slotsForSplitCount(2, ['trade.buySell', 'trade.orderbook']),
},
bottom: {
mode: 'single',
splitCount: 1,
slots: [newWidgetSlot('trade.history')],
},
},
collapsed: {},
panelSizes: {},
};
}
/** splitCount 변경 시 슬롯 수 맞춤 */
export function normalizeZoneSlots(zone: WidgetZoneConfig): WidgetZoneConfig {
if (zone.mode === 'tabs') {
return {
...zone,
tabs: (zone.tabs ?? []).map(tab => {
const count = tab.splitCount ?? 1;
const slots = tab.slots ?? [];
if (slots.length === count) return tab;
if (slots.length > count) return { ...tab, slots: slots.slice(0, count) };
return {
...tab,
slots: [
...slots,
...Array.from({ length: count - slots.length }, () => newWidgetSlot()),
],
};
}),
};
}
const count = zone.splitCount ?? 1;
const slots = zone.slots ?? [];
if (slots.length === count) return zone;
if (slots.length > count) return { ...zone, slots: slots.slice(0, count) };
return {
...zone,
slots: [
...slots,
...Array.from({ length: count - slots.length }, () => newWidgetSlot()),
],
};
}
export function normalizeWidgetLayout(raw: unknown): WidgetLayoutSchema {
const base = defaultWidgetLayout();
if (!raw || typeof raw !== 'object') return base;
const o = raw as Partial<WidgetLayoutSchema>;
const zonesRaw = (o.zones ?? {}) as Partial<WidgetLayoutSchema['zones']>;
const zones = zonesRaw;
const normZone = (z: WidgetZoneConfig | null | undefined, fallback: WidgetZoneConfig | null): WidgetZoneConfig | null => {
if (z === null) return null;
if (!z) return fallback;
return normalizeZoneSlots(z);
};
return {
v: 1,
zones: {
left: normZone(zones.left as WidgetZoneConfig | null | undefined, base.zones.left),
center: normZone(zones.center as WidgetZoneConfig | undefined, base.zones.center)!,
right: normZone(zones.right as WidgetZoneConfig | null | undefined, base.zones.right),
bottom: normZone(zones.bottom as WidgetZoneConfig | null | undefined, base.zones.bottom),
},
collapsed: { ...base.collapsed, ...(o.collapsed ?? {}) },
panelSizes: { ...base.panelSizes, ...(o.panelSizes ?? {}) },
};
}
export function zoneSlotCount(zone: WidgetZoneConfig): number {
if (zone.mode === 'tabs') {
return (zone.tabs ?? []).reduce((n, t) => n + (t.slots?.length ?? t.splitCount ?? 1), 0);
}
return zone.slots?.length ?? zone.splitCount ?? 1;
}