Files
goldenChart/frontend/src/components/PlotLineStylePicker.tsx
T
2026-05-23 15:11:48 +09:00

271 lines
7.8 KiB
TypeScript

import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import type { HLineStyle } from '../utils/indicatorRegistry';
import {
parsePlotColor,
formatPlotColor,
plotColorCss,
LINE_WIDTH_OPTIONS,
LINE_STYLE_OPTIONS,
LINE_STYLE_LABELS,
} from '../utils/plotColorUtils';
import ColorPickerPanel from './ColorPickerPanel';
export interface PlotLineStyleValue {
color: string;
lineWidth?: number;
lineStyle?: HLineStyle;
}
interface PlotLineStylePickerProps {
value: PlotLineStyleValue;
disabled?: boolean;
/** 팝업 제목 (예: MA3) */
title?: string;
/** false면 색상만 (히스토그램 등) */
showLineControls?: boolean;
onChange: (patch: Partial<PlotLineStyleValue>) => void;
}
interface PlotLineStylePopupProps {
title?: string;
showLineControls: boolean;
hex6: string;
alpha: number;
lineWidth: number;
lineStyle: HLineStyle;
onChange: (patch: Partial<PlotLineStyleValue>) => void;
onClose: () => void;
patchColor: (hex: string, alphaPct: number) => void;
}
const PlotLineStylePopup: React.FC<PlotLineStylePopupProps> = ({
title,
showLineControls,
hex6,
alpha,
lineWidth,
lineStyle,
onChange,
onClose,
patchColor,
}) => {
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
return (
<div className="plsp-portal-root">
<div
className="plsp-overlay"
role="presentation"
onMouseDown={onClose}
/>
<div
ref={panelRef}
className="plsp-popup"
role="dialog"
aria-modal="true"
aria-label={title ? `${title} 선 스타일` : '선 스타일'}
style={{
...panelStyle,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header plsp-popup-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title plsp-popup-title">
{title ? `${title} · ` : ''}색상 · 스타일
</span>
<button type="button" className="gc-popup-close plsp-popup-close" onClick={onClose} aria-label="닫기">
</button>
</div>
<ColorPickerPanel
hex6={hex6}
onHexChange={h => patchColor(h, alpha)}
/>
<div className="plsp-section">
<span className="plsp-section-label">불투명성</span>
<div className="plsp-opacity-row">
<input
type="range"
className="plsp-opacity-slider"
min={0}
max={100}
value={alpha}
onChange={e => patchColor(hex6, Number(e.target.value))}
/>
<span className="plsp-opacity-pct">{alpha}%</span>
</div>
</div>
{showLineControls && (
<>
<div className="plsp-section">
<span className="plsp-section-label">두께</span>
<div className="plsp-btn-group">
{LINE_WIDTH_OPTIONS.map(w => (
<button
key={w}
type="button"
className={`plsp-icon-btn${lineWidth === w ? ' active' : ''}`}
title={`${w}px`}
onClick={() => onChange({ lineWidth: w })}
>
<svg width="24" height="14" viewBox="0 0 24 14" aria-hidden>
<line x1="2" y1="7" x2="22" y2="7" stroke="currentColor" strokeWidth={w} strokeLinecap="round" />
</svg>
</button>
))}
</div>
</div>
<div className="plsp-section">
<span className="plsp-section-label">라인 스타일</span>
<div className="plsp-btn-group">
{LINE_STYLE_OPTIONS.map(s => (
<button
key={s}
type="button"
className={`plsp-icon-btn${lineStyle === s ? ' active' : ''}`}
title={LINE_STYLE_LABELS[s]}
onClick={() => onChange({ lineStyle: s })}
>
<LineStyleIcon style={s} />
</button>
))}
</div>
</div>
</>
)}
<div className="plsp-popup-footer">
<button type="button" className="plsp-popup-ok" onClick={onClose}>
확인
</button>
</div>
</div>
</div>
);
};
const LineStyleIcon: React.FC<{ style: HLineStyle }> = ({ style }) => (
<svg width="28" height="14" viewBox="0 0 28 14" aria-hidden>
<line
x1="2" y1="7" x2="26" y2="7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={
style === 'dashed' ? '6 4' : style === 'dotted' ? '2 3' : undefined
}
/>
</svg>
);
const PlotLineStylePicker: React.FC<PlotLineStylePickerProps> = ({
value,
disabled = false,
title,
showLineControls = true,
onChange,
}) => {
const [open, setOpen] = useState(false);
const swatchRef = useRef<HTMLButtonElement>(null);
const { hex6, alpha } = parsePlotColor(value.color);
const lineWidth = value.lineWidth ?? 1;
const lineStyle = value.lineStyle ?? 'solid';
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, close]);
const patchColor = useCallback((hex: string, alphaPct: number) => {
onChange({ color: formatPlotColor(hex, alphaPct) });
}, [onChange]);
const popup = open ? (
<PlotLineStylePopup
title={title}
showLineControls={showLineControls}
hex6={hex6}
alpha={alpha}
lineWidth={lineWidth}
lineStyle={lineStyle}
onChange={onChange}
onClose={close}
patchColor={patchColor}
/>
) : null;
return (
<div className={`plsp-inline${disabled ? ' plsp-inline--disabled' : ''}`}>
<button
ref={swatchRef}
type="button"
className="plsp-swatch"
disabled={disabled}
title="색상·선 스타일"
onClick={() => !disabled && setOpen(true)}
>
<span
className="plsp-swatch-inner"
style={{ background: plotColorCss(value.color) }}
/>
</button>
{showLineControls && (
<>
<select
className="plsp-inline-select"
value={lineWidth}
disabled={disabled}
title="선 굵기"
aria-label="선 굵기"
onChange={e => onChange({ lineWidth: Number(e.target.value) })}
>
{LINE_WIDTH_OPTIONS.map(w => (
<option key={w} value={w}>{w}</option>
))}
</select>
<select
className="plsp-inline-select plsp-inline-select--style"
value={lineStyle}
disabled={disabled}
title="선 유형"
aria-label="선 유형"
onChange={e => onChange({ lineStyle: e.target.value as HLineStyle })}
>
{LINE_STYLE_OPTIONS.map(s => (
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
))}
</select>
</>
)}
{typeof document !== 'undefined' && popup
? createPortal(popup, document.body)
: null}
</div>
);
};
export default PlotLineStylePicker;