UNPKG

chart-0714

Version:

Professional trading chart library with advanced customization for trading journal apps

1,596 lines (1,585 loc) 393 kB
import require$$0, { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from "react"; class DataManager { data = null; constructor() { } /** * 캔들 배열을 TypedArray 기반 ChartData로 변환 */ setData(candles) { if (!candles || candles.length === 0) { this.data = null; return; } const length = candles.length; const times = new Float64Array(length); const opens = new Float32Array(length); const highs = new Float32Array(length); const lows = new Float32Array(length); const closes = new Float32Array(length); const volumes = new Float32Array(length); let minPrice = Infinity; let maxPrice = -Infinity; let minTime = Infinity; let maxTime = -Infinity; for (let i = 0; i < length; i++) { const candle = candles[i]; times[i] = candle.time; opens[i] = candle.open; highs[i] = candle.high; lows[i] = candle.low; closes[i] = candle.close; volumes[i] = candle.volume; minPrice = Math.min(minPrice, candle.low); maxPrice = Math.max(maxPrice, candle.high); minTime = Math.min(minTime, candle.time); maxTime = Math.max(maxTime, candle.time); } this.data = { times, opens, highs, lows, closes, volumes, length, timeRange: [minTime, maxTime], priceRange: [minPrice, maxPrice] }; } /** * 시간 범위에 해당하는 데이터 인덱스 찾기 (이진 탐색) */ findVisibleRange(startTime, endTime) { if (!this.data) return null; const { times, length } = this.data; let start = 0; let end = length - 1; let startIndex = 0; while (start <= end) { const mid = Math.floor((start + end) / 2); if (times[mid] < startTime) { start = mid + 1; } else { startIndex = mid; end = mid - 1; } } start = startIndex; end = length - 1; let endIndex = length - 1; while (start <= end) { const mid = Math.floor((start + end) / 2); if (times[mid] <= endTime) { endIndex = mid; start = mid + 1; } else { end = mid - 1; } } startIndex = Math.max(0, startIndex - 2); endIndex = Math.min(length - 1, endIndex + 2); return { start: startIndex, end: endIndex }; } /** * 특정 인덱스의 캔들 데이터 가져오기 */ getCandle(index) { if (!this.data || index < 0 || index >= this.data.length) { return null; } return { time: this.data.times[index], open: this.data.opens[index], high: this.data.highs[index], low: this.data.lows[index], close: this.data.closes[index], volume: this.data.volumes[index] }; } /** * 가격 범위 계산 (보이는 영역) */ getPriceRange(startIndex, endIndex) { if (!this.data) return null; let minPrice = Infinity; let maxPrice = -Infinity; for (let i = startIndex; i <= endIndex && i < this.data.length; i++) { minPrice = Math.min(minPrice, this.data.lows[i]); maxPrice = Math.max(maxPrice, this.data.highs[i]); } const padding = (maxPrice - minPrice) * 0.1; return [minPrice - padding, maxPrice + padding]; } /** * 시간에 해당하는 정확한 캔들 인덱스 찾기 */ getIndexForTime(time) { if (!this.data || this.data.length === 0) return -1; const { times, length } = this.data; let left = 0; let right = length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); if (times[mid] === time) { return mid; } else if (times[mid] < time) { left = mid + 1; } else { right = mid - 1; } } return this.findNearestIndex(time); } /** * 시간에 가장 가까운 캔들 인덱스 찾기 */ findNearestIndex(time) { if (!this.data || this.data.length === 0) return -1; const { times, length } = this.data; let left = 0; let right = length - 1; let nearest = 0; let minDiff = Math.abs(times[0] - time); while (left <= right) { const mid = Math.floor((left + right) / 2); const diff = Math.abs(times[mid] - time); if (diff < minDiff) { minDiff = diff; nearest = mid; } if (times[mid] < time) { left = mid + 1; } else if (times[mid] > time) { right = mid - 1; } else { return mid; } } return nearest; } getData() { return this.data; } isEmpty() { return !this.data || this.data.length === 0; } getLength() { return this.data?.length ?? 0; } /** * 평균 캔들 간격 계산 (초 단위) */ getCandleInterval() { if (!this.data || this.data.length < 2) return 60; const sampleSize = Math.min(10, this.data.length - 1); let totalInterval = 0; for (let i = 1; i <= sampleSize; i++) { totalInterval += this.data.times[i] - this.data.times[i - 1]; } return totalInterval / sampleSize; } } class DrawingSnapManager { isMagneticMode = true; // 기본값 true로 변경 currentSnapPoint = null; onSnapChange; setMagneticMode(enabled) { this.isMagneticMode = enabled; if (!enabled) { this.updateSnapPoint(null); } } getMagneticMode() { return this.isMagneticMode; } setSnapChangeCallback(callback) { this.onSnapChange = callback; } updateSnapPoint(point) { this.currentSnapPoint = point; if (this.onSnapChange) { this.onSnapChange(point); } } findSnapPoint(mouseX, mouseY, candles, coordSystem, threshold = 30) { if (!this.isMagneticMode || !candles || candles.length === 0) return null; const canvasCoords = coordSystem.cssToCanvas(mouseX, mouseY); const centerIndex = Math.round(coordSystem.canvasXToIndex(canvasCoords.x)); const searchRange = 2; let closestPoint = null; let minDistance = Infinity; for (let i = centerIndex - searchRange; i <= centerIndex + searchRange; i++) { if (i < 0 || i >= candles.length) continue; const candle = candles[i]; if (!candle) continue; const points = [ { price: candle.high, type: "high" }, { price: candle.low, type: "low" }, { price: candle.open, type: "open" }, { price: candle.close, type: "close" } ]; for (const point of points) { const cssCoords = coordSystem.chartToCSS(i, point.price, "main"); const dx = cssCoords.x - mouseX; const dy = cssCoords.y - mouseY; const distance = Math.sqrt(dx * dx + dy * dy); if (distance < minDistance) { minDistance = distance; closestPoint = { x: cssCoords.x, y: cssCoords.y, type: point.type, price: point.price, time: candle.time, index: i }; } } } if (closestPoint && minDistance < threshold) { this.updateSnapPoint(closestPoint); return closestPoint; } this.updateSnapPoint(null); return null; } getCurrentSnapPoint() { return this.currentSnapPoint; } } class DrawingMeasureManager { measureMode = false; measureStartPoint = null; measureEndPoint = null; measureFixed = false; onMeasureChange; setMeasureChangeCallback(callback) { this.onMeasureChange = callback; } startMeasure(point) { this.measureMode = true; this.measureStartPoint = point; this.measureEndPoint = null; this.measureFixed = false; if (this.onMeasureChange) { this.onMeasureChange(this.measureStartPoint, null, false); } } updateMeasure(endPoint) { if (!this.measureMode || !this.measureStartPoint || this.measureFixed) return; this.measureEndPoint = endPoint; if (this.onMeasureChange) { this.onMeasureChange(this.measureStartPoint, this.measureEndPoint, false); } } fixMeasure(endPoint) { if (!this.measureMode || !this.measureStartPoint) return; this.measureEndPoint = endPoint; this.measureFixed = true; if (this.onMeasureChange) { this.onMeasureChange(this.measureStartPoint, this.measureEndPoint, true); } } cancelMeasure() { this.measureMode = false; this.measureStartPoint = null; this.measureEndPoint = null; this.measureFixed = false; if (this.onMeasureChange) { this.onMeasureChange(null, null, false); } } isMeasureMode() { return this.measureMode; } getMeasurePoints() { return { start: this.measureStartPoint, end: this.measureEndPoint, fixed: this.measureFixed }; } isMeasureFixed() { return this.measureFixed; } } class DrawingObjectManager { drawings = []; selectedDrawing = null; nextId = 1; addDrawing(drawing) { this.drawings.push(drawing); } removeDrawing(id) { this.drawings = this.drawings.filter((d) => d.id !== id); if (this.selectedDrawing?.id === id) { this.selectedDrawing = null; } } getAllDrawings() { return [...this.drawings]; } getSelectedDrawing() { return this.selectedDrawing; } selectDrawing(drawing) { if (this.selectedDrawing) { this.selectedDrawing.selected = false; } this.selectedDrawing = drawing; if (drawing) { drawing.selected = true; } } selectDrawingAt(x, y, coordSystem, tolerance = 10) { const chartCoords = coordSystem.cssToChart(x, y, "main"); const clickTime = chartCoords.time; const clickPrice = chartCoords.price; for (let i = this.drawings.length - 1; i >= 0; i--) { const drawing = this.drawings[i]; if (this.isPointOnDrawing(drawing, clickTime, clickPrice, coordSystem, tolerance)) { this.selectDrawing(drawing); return drawing; } } this.selectDrawing(null); return null; } checkDrawingAt(x, y, coordSystem, tolerance = 10) { const chartCoords = coordSystem.cssToChart(x, y, "main"); const clickTime = chartCoords.time; const clickPrice = chartCoords.price; for (let i = this.drawings.length - 1; i >= 0; i--) { const drawing = this.drawings[i]; if (this.isPointOnDrawing(drawing, clickTime, clickPrice, coordSystem, tolerance)) { return drawing; } } return null; } isPointOnDrawing(drawing, time, price, coordSystem, tolerance) { switch (drawing.type) { case "trendline": case "horizontal": return this.isPointOnLine(drawing, time, price, coordSystem, tolerance); case "text": case "marker": return this.isPointOnMarker(drawing, time, price, coordSystem, tolerance); case "circle": return this.isPointOnCircle(drawing, time, price, coordSystem, tolerance); case "rectangle": return this.isPointOnRectangle(drawing, time, price, coordSystem, tolerance); default: return false; } } isPointOnLine(drawing, time, price, coordSystem, tolerance) { if (drawing.points.length < 2) return false; const p1 = drawing.points[0]; const p2 = drawing.points[1]; if (drawing.type === "horizontal") { const lineCSS = coordSystem.chartToCSS(p1.time, p1.price, "main"); const clickCSS2 = coordSystem.chartToCSS(time, price, "main"); return Math.abs(lineCSS.y - clickCSS2.y) <= tolerance; } const p1CSS = coordSystem.chartToCSS(p1.time, p1.price, "main"); const p2CSS = coordSystem.chartToCSS(p2.time, p2.price, "main"); const clickCSS = coordSystem.chartToCSS(time, price, "main"); const x1 = p1CSS.x; const y1 = p1CSS.y; const x2 = p2CSS.x; const y2 = p2CSS.y; const px = clickCSS.x; const py = clickCSS.y; const distance = this.pointToLineDistance(px, py, x1, y1, x2, y2); return distance <= tolerance; } isPointOnMarker(drawing, time, price, coordSystem, tolerance) { if (drawing.points.length === 0) return false; const markerPoint = drawing.points[0]; const markerCSS = coordSystem.chartToCSS(markerPoint.time, markerPoint.price, "main"); const clickCSS = coordSystem.chartToCSS(time, price, "main"); const markerX = markerCSS.x; const markerY = markerCSS.y; const clickX = clickCSS.x; const clickY = clickCSS.y; const distance = Math.sqrt(Math.pow(markerX - clickX, 2) + Math.pow(markerY - clickY, 2)); return distance <= tolerance * 2; } isPointOnCircle(drawing, time, price, coordSystem, tolerance) { if (drawing.points.length === 0 || drawing.radius === void 0) return false; const center = drawing.points[0]; const centerCSS = coordSystem.chartToCSS(center.time, center.price, "main"); const clickCSS = coordSystem.chartToCSS(time, price, "main"); const centerX = centerCSS.x; const centerY = centerCSS.y; const clickX = clickCSS.x; const clickY = clickCSS.y; const distance = Math.sqrt(Math.pow(centerX - clickX, 2) + Math.pow(centerY - clickY, 2)); return Math.abs(distance - drawing.radius) <= tolerance || distance <= drawing.radius; } isPointOnRectangle(drawing, time, price, coordSystem, tolerance) { if (drawing.points.length < 2) return false; const p1 = drawing.points[0]; const p2 = drawing.points[1]; const p1CSS = coordSystem.chartToCSS(p1.time, p1.price, "main"); const p2CSS = coordSystem.chartToCSS(p2.time, p2.price, "main"); const x1 = p1CSS.x; const y1 = p1CSS.y; const x2 = p2CSS.x; const y2 = p2CSS.y; const clickCSS = coordSystem.chartToCSS(time, price, "main"); const px = clickCSS.x; const py = clickCSS.y; const minX = Math.min(x1, x2); const maxX = Math.max(x1, x2); const minY = Math.min(y1, y2); const maxY = Math.max(y1, y2); const nearLeft = Math.abs(px - minX) <= tolerance && py >= minY - tolerance && py <= maxY + tolerance; const nearRight = Math.abs(px - maxX) <= tolerance && py >= minY - tolerance && py <= maxY + tolerance; const nearTop = Math.abs(py - minY) <= tolerance && px >= minX - tolerance && px <= maxX + tolerance; const nearBottom = Math.abs(py - maxY) <= tolerance && px >= minX - tolerance && px <= maxX + tolerance; return nearLeft || nearRight || nearTop || nearBottom; } pointToLineDistance(px, py, x1, y1, x2, y2) { const A = px - x1; const B = py - y1; const C = x2 - x1; const D = y2 - y1; const dot = A * C + B * D; const lenSq = C * C + D * D; let param = -1; if (lenSq !== 0) { param = dot / lenSq; } let xx, yy; if (param < 0) { xx = x1; yy = y1; } else if (param > 1) { xx = x2; yy = y2; } else { xx = x1 + param * C; yy = y1 + param * D; } const dx = px - xx; const dy = py - yy; return Math.sqrt(dx * dx + dy * dy); } deleteSelectedDrawing() { if (this.selectedDrawing) { this.removeDrawing(this.selectedDrawing.id); return true; } return false; } createDrawing(type, points, style, extras) { const bgColor = document.body.style.backgroundColor; const isLightTheme = bgColor === "rgb(255, 255, 255)" || bgColor === "#ffffff"; const defaultColor = isLightTheme ? "#000000" : "#FFFFFF"; const drawing = { id: `drawing_${this.nextId++}`, type, points: [...points], style: { color: style?.color || defaultColor, lineWidth: style?.lineWidth || 2, opacity: style?.opacity || 1 }, selected: false, locked: false, ...extras }; return drawing; } updateDrawing(id, updates) { const drawing = this.drawings.find((d) => d.id === id); if (drawing) { Object.assign(drawing, updates); } } clear() { this.drawings = []; this.selectedDrawing = null; } getDrawingsCount() { return this.drawings.length; } } class DrawingStateManager { history = []; currentIndex = -1; maxHistorySize = 50; saveState(drawings) { this.history = this.history.slice(0, this.currentIndex + 1); const newState = { drawings: this.deepCloneDrawings(drawings), timestamp: Date.now() }; this.history.push(newState); this.currentIndex++; if (this.history.length > this.maxHistorySize) { this.history.shift(); this.currentIndex--; } } canUndo() { return this.currentIndex > 0; } canRedo() { return this.currentIndex < this.history.length - 1; } undo() { if (!this.canUndo()) return null; this.currentIndex--; return this.deepCloneDrawings(this.history[this.currentIndex].drawings); } redo() { if (!this.canRedo()) return null; this.currentIndex++; return this.deepCloneDrawings(this.history[this.currentIndex].drawings); } deepCloneDrawings(drawings) { return drawings.map((drawing) => ({ ...drawing, points: drawing.points.map((point) => ({ ...point })), style: { ...drawing.style } })); } clear() { this.history = []; this.currentIndex = -1; } getHistoryInfo() { return { current: this.currentIndex + 1, total: this.history.length }; } exportState() { const currentState = this.history[this.currentIndex]; if (!currentState) return "[]"; return JSON.stringify(currentState.drawings); } importState(jsonData) { try { const drawings = JSON.parse(jsonData); if (!Array.isArray(drawings)) { throw new Error("Invalid drawing data: not an array"); } return drawings; } catch (error) { console.error("Failed to import drawings:", error); throw error; } } } class DrawingToolHandler { currentTool = null; isDrawing = false; firstPoint = null; tempDrawing = null; onTextInput; setCurrentTool(tool) { this.currentTool = tool; this.cancelDrawing(); } getCurrentTool() { return this.currentTool; } isCurrentlyDrawing() { return this.isDrawing; } getFirstPoint() { return this.firstPoint; } getTempDrawing() { return this.tempDrawing; } setTextInputCallback(callback) { this.onTextInput = callback; } startDrawing(firstPoint, style) { if (!this.currentTool) return null; this.isDrawing = true; this.firstPoint = firstPoint; switch (this.currentTool) { case "trendline": this.tempDrawing = { id: "temp", type: "trendline", points: [firstPoint, firstPoint], // 두 번째 점은 임시 style, selected: false, locked: false }; break; case "horizontal": this.tempDrawing = { id: "temp", type: "horizontal", points: [firstPoint], style, selected: false, locked: false }; break; case "circle": this.tempDrawing = { id: "temp", type: "circle", points: [firstPoint], radius: 0, style, selected: false, locked: false }; break; case "rectangle": this.tempDrawing = { id: "temp", type: "rectangle", points: [firstPoint, firstPoint], style, selected: false, locked: false }; break; case "text": case "marker": this.isDrawing = false; this.firstPoint = null; return null; } return this.tempDrawing; } updateDrawing(currentPoint, coordSystem) { if (!this.isDrawing || !this.tempDrawing || !this.firstPoint) return; switch (this.tempDrawing.type) { case "trendline": this.tempDrawing.points[1] = currentPoint; break; case "circle": if (coordSystem && coordSystem.chartToCanvas) { const p1 = coordSystem.chartToCanvas(this.firstPoint.time, this.firstPoint.price); const p2 = coordSystem.chartToCanvas(currentPoint.time, currentPoint.price); const dx = p2.x - p1.x; const dy = p2.y - p1.y; this.tempDrawing.radius = Math.sqrt(dx * dx + dy * dy); } else { const dx = currentPoint.time - this.firstPoint.time; const dy = currentPoint.price - this.firstPoint.price; this.tempDrawing.radius = Math.sqrt(dx * dx + dy * dy) * 20; } break; case "rectangle": this.tempDrawing.points[1] = currentPoint; break; } } finishDrawing() { if (!this.isDrawing || !this.tempDrawing) return null; const finishedDrawing = { ...this.tempDrawing }; this.isDrawing = false; this.firstPoint = null; this.tempDrawing = null; return finishedDrawing; } cancelDrawing() { this.isDrawing = false; this.firstPoint = null; this.tempDrawing = null; } shouldHandleTextInput(tool) { return tool === "text"; } requestTextInput(point, callback) { if (this.onTextInput) { this.onTextInput(point, callback); } } createImmediateDrawing(type, point, style) { switch (type) { case "horizontal": return { id: `drawing_${Date.now()}`, type: "horizontal", points: [point], style, selected: false, locked: false }; case "marker": return { id: `drawing_${Date.now()}`, type: "marker", points: [point], style, selected: false, locked: false, markerType: style.markerType || "arrowUp", markerPosition: style.markerPosition || "above" }; default: return null; } } } class TextRenderer { container; textElements = /* @__PURE__ */ new Map(); coordSystem; debugLogged = false; onStyleEdit; isEditing = false; constructor(container, coordSystem) { this.container = container; this.coordSystem = coordSystem; this.createTextContainer(); } createTextContainer() { let textContainer = this.container.querySelector(".chart-text-container"); if (!textContainer) { textContainer = document.createElement("div"); textContainer.className = "chart-text-container"; textContainer.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; z-index: 100; `; this.container.appendChild(textContainer); const style = document.createElement("style"); style.textContent = ` .chart-text[contenteditable="true"].empty:before { content: attr(data-placeholder); color: #999; font-style: italic; } .chart-text[contenteditable="true"] { min-width: 100px !important; min-height: 20px !important; } `; document.head.appendChild(style); } } /** * 텍스트 객체 렌더링 */ renderText(textObject) { let element = this.textElements.get(textObject.id); if (!element) { if (!this.debugLogged) { console.log("[TextRenderer] Creating first text element:", textObject); this.debugLogged = true; } element = this.createTextElement(textObject); this.textElements.set(textObject.id, element); } this.updateTextPosition(element, textObject); this.updateTextStyle(element, textObject); if (element.textContent !== textObject.text) { element.textContent = textObject.text; } } /** * 새 텍스트 생성 및 즉시 편집 모드 */ createAndEditText(point, style, onComplete) { console.log("[TextRenderer] createAndEditText called with:", { point, style }); if (this.isEditing) { console.log("[TextRenderer] Already editing, ignoring"); return; } this.isEditing = true; const tempId = `temp-${Date.now()}`; const tempObject = { id: tempId, type: "text", points: [point], text: "", textStyle: style, style: { color: style.color, lineWidth: 1, opacity: 1 }, selected: false, locked: false }; const element = this.createTextElement(tempObject); this.textElements.set(tempId, element); console.log("[TextRenderer] Created temp element:", element); this.updateTextPosition(element, tempObject); this.updateTextStyle(element, tempObject); element.style.transform = "none"; element.contentEditable = "true"; element.style.cursor = "text"; element.style.userSelect = "text"; element.style.minWidth = "100px"; element.style.minHeight = "20px"; element.style.outline = "2px solid #2196F3"; element.style.padding = "4px"; element.style.backgroundColor = "rgba(255, 255, 255, 0.9)"; element.style.color = "#000000"; element.style.zIndex = "1000"; element.style.pointerEvents = "auto"; element.setAttribute("data-placeholder", "Type text..."); if (!element.textContent) { element.classList.add("empty"); } element.focus(); console.log("[TextRenderer] Element focused immediately"); const finishEdit = () => { const text = element.textContent || ""; this.isEditing = false; element.remove(); this.textElements.delete(tempId); if (text.trim()) { onComplete(text.trim()); } }; let isFinishing = false; const handleBlur = () => { if (isFinishing) return; setTimeout(() => { if (document.activeElement === element) return; finishEdit(); }, 200); }; const handleKeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); isFinishing = true; finishEdit(); } else if (e.key === "Escape") { e.preventDefault(); element.textContent = ""; isFinishing = true; finishEdit(); } }; const handleInput = () => { if (element.textContent) { element.classList.remove("empty"); } else { element.classList.add("empty"); } }; const handleMouseDown = (e) => { e.stopPropagation(); }; element.addEventListener("blur", handleBlur); element.addEventListener("keydown", handleKeydown); element.addEventListener("input", handleInput); element.addEventListener("mousedown", handleMouseDown); } /** * 텍스트 요소 생성 */ createTextElement(textObject) { const element = document.createElement("div"); element.className = "chart-text"; element.id = `text-${textObject.id}`; element.style.cssText = ` position: absolute; pointer-events: auto; cursor: move; user-select: none; white-space: nowrap; transform: translate(-50%, -50%); `; element.textContent = textObject.text || ""; if (textObject.selected) { element.classList.add("selected"); element.style.outline = "2px solid #2196F3"; element.style.outlineOffset = "2px"; } element.addEventListener("dblclick", (e) => { e.stopPropagation(); this.handleDoubleClick(textObject.id); }); const textContainer = this.container.querySelector(".chart-text-container"); if (textContainer) { textContainer.appendChild(element); } return element; } /** * 텍스트 위치 업데이트 */ updateTextPosition(element, textObject) { if (!textObject.points || textObject.points.length === 0) { console.warn("[TextRenderer] No points in text object:", textObject); return; } const point = textObject.points[0]; const canvasPoint = this.coordSystem.chartToCanvas(point.time, point.price); const pixelRatio = this.coordSystem.getPixelRatio ? this.coordSystem.getPixelRatio() : 1; const cssPoint = { x: canvasPoint.x / pixelRatio, y: canvasPoint.y / pixelRatio }; if (!this.debugLogged) { console.log("[TextRenderer] Position calculation:", { chartPoint: point, canvasPoint, cssPoint, pixelRatio, containerRect: this.container.getBoundingClientRect() }); } element.style.left = `${cssPoint.x}px`; element.style.top = `${cssPoint.y}px`; } /** * 텍스트 스타일 업데이트 */ updateTextStyle(element, textObject) { const defaultStyle = { fontSize: 14, fontFamily: "Arial, sans-serif", color: "#000000", bold: false, italic: false, underline: false, padding: 4 }; const style = { ...defaultStyle, ...textObject.textStyle }; element.style.fontSize = `${style.fontSize}px`; element.style.fontFamily = style.fontFamily; element.style.color = style.color; element.style.fontWeight = style.bold ? "bold" : "normal"; element.style.fontStyle = style.italic ? "italic" : "normal"; element.style.textDecoration = style.underline ? "underline" : "none"; if (style.backgroundColor) { element.style.backgroundColor = style.backgroundColor; } else { element.style.backgroundColor = "transparent"; } if (style.borderColor && style.borderWidth) { element.style.border = `${style.borderWidth}px solid ${style.borderColor}`; } else { element.style.border = "none"; } element.style.padding = `${style.padding || 4}px`; if (textObject.selected) { element.style.outline = "2px solid #2196F3"; element.style.outlineOffset = "2px"; } else { element.style.outline = "none"; } } /** * 모든 텍스트 재렌더링 (줌/패닝 시) */ updateAllPositions(textObjects) { const currentIds = new Set(textObjects.map((obj) => obj.id)); this.textElements.forEach((element, id) => { if (!currentIds.has(id)) { element.remove(); this.textElements.delete(id); } }); textObjects.forEach((textObj) => { this.renderText(textObj); }); } /** * 텍스트 제거 */ removeText(id) { const element = this.textElements.get(id); if (element) { element.remove(); this.textElements.delete(id); } } /** * 모든 텍스트 제거 */ clear() { this.textElements.forEach((element) => element.remove()); this.textElements.clear(); } /** * 텍스트 선택 상태 업데이트 */ updateSelection(id, selected) { const element = this.textElements.get(id); if (element) { if (selected) { element.classList.add("selected"); element.style.outline = "2px solid #2196F3"; element.style.outlineOffset = "2px"; element.style.pointerEvents = "auto"; } else { element.classList.remove("selected"); element.style.outline = "none"; element.style.pointerEvents = "auto"; } } } /** * 편집 모드 활성화 */ enableEdit(id, onComplete) { const element = this.textElements.get(id); if (!element) return; element.contentEditable = "true"; element.style.cursor = "text"; element.style.userSelect = "text"; element.style.outline = "2px solid #2196F3"; element.focus(); const range = document.createRange(); range.selectNodeContents(element); const selection = window.getSelection(); if (selection) { selection.removeAllRanges(); selection.addRange(range); } const finishEdit = () => { element.contentEditable = "false"; element.style.cursor = "move"; element.style.userSelect = "none"; const newText = element.textContent || ""; onComplete(newText); element.removeEventListener("blur", handleBlur); element.removeEventListener("keydown", handleKeydown); }; const handleBlur = () => finishEdit(); const handleKeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); finishEdit(); } else if (e.key === "Escape") { e.preventDefault(); element.blur(); } }; element.addEventListener("blur", handleBlur); element.addEventListener("keydown", handleKeydown); } /** * 더블클릭 핸들러 */ handleDoubleClick(id) { if (this.onStyleEdit) { this.onStyleEdit(id, {}); } } /** * 스타일 편집 콜백 설정 */ setStyleEditCallback(callback) { this.onStyleEdit = callback; } /** * ID로 텍스트 객체 가져오기 (외부 관리자에서 호출) */ getTextObjectById(id) { return null; } /** * 편집 중인지 확인 */ isTextEditing() { return this.isEditing; } /** * 컨테이너 정리 */ dispose() { this.clear(); const textContainer = this.container.querySelector(".chart-text-container"); if (textContainer) { textContainer.remove(); } } } class TextInputModal { container; modal = null; options = null; constructor(container) { this.container = container; } show(options) { this.options = options; this.createModal(); } createModal() { const backdrop = document.createElement("div"); backdrop.className = "text-input-backdrop"; backdrop.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 100; display: flex; align-items: center; justify-content: center; `; this.modal = document.createElement("div"); this.modal.className = "text-input-modal"; this.modal.style.cssText = ` background: white; color: #333333; border-radius: 8px; padding: 20px; min-width: 400px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); `; const defaultStyle = { fontSize: 14, fontFamily: "Arial, sans-serif", color: "#000000", bold: false, italic: false, underline: false }; const style = { ...defaultStyle, ...this.options?.initialStyle }; this.modal.innerHTML = ` <div style="margin-bottom: 16px;"> <h3 style="margin: 0 0 16px 0; font-size: 18px; color: #333;">텍스트 입력</h3> <textarea id="text-input" style=" width: 100%; min-height: 60px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; color: #333; background: white; resize: vertical; " placeholder="텍스트를 입력하세요..." >${this.options?.initialText || ""}</textarea> </div> <div style="margin-bottom: 16px;"> <h4 style="margin: 0 0 8px 0; font-size: 14px; color: #333;">텍스트 스타일</h4> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;"> <div> <label style="display: block; margin-bottom: 4px; font-size: 12px; color: #666;">폰트 크기</label> <input type="number" id="font-size" value="${style.fontSize}" min="8" max="72" style="width: 100%; padding: 4px; border: 1px solid #ddd; border-radius: 4px; color: #333; background: white;" > </div> <div> <label style="display: block; margin-bottom: 4px; font-size: 12px; color: #666;">폰트</label> <select id="font-family" style="width: 100%; padding: 4px; border: 1px solid #ddd; border-radius: 4px; color: #333; background: white;" > <option value="Arial, sans-serif" ${style.fontFamily === "Arial, sans-serif" ? "selected" : ""}>Arial</option> <option value="'Times New Roman', serif" ${style.fontFamily === "'Times New Roman', serif" ? "selected" : ""}>Times New Roman</option> <option value="'Courier New', monospace" ${style.fontFamily === "'Courier New', monospace" ? "selected" : ""}>Courier New</option> <option value="Georgia, serif" ${style.fontFamily === "Georgia, serif" ? "selected" : ""}>Georgia</option> <option value="Verdana, sans-serif" ${style.fontFamily === "Verdana, sans-serif" ? "selected" : ""}>Verdana</option> <option value="'Trebuchet MS', sans-serif" ${style.fontFamily === "'Trebuchet MS', sans-serif" ? "selected" : ""}>Trebuchet MS</option> </select> </div> <div> <label style="display: block; margin-bottom: 4px; font-size: 12px; color: #666;">텍스트 색상</label> <input type="color" id="text-color" value="${style.color}" style="width: 100%; height: 32px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" > </div> <div> <label style="display: block; margin-bottom: 4px; font-size: 12px; color: #666;">배경 색상</label> <div style="display: flex; gap: 4px;"> <input type="checkbox" id="use-background" ${style.backgroundColor ? "checked" : ""} style="margin-top: 8px;" > <input type="color" id="background-color" value="${style.backgroundColor || "#ffffff"}" ${!style.backgroundColor ? "disabled" : ""} style="flex: 1; height: 32px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;" > </div> </div> </div> <div style="margin-top: 12px; display: flex; gap: 12px;"> <label style="display: flex; align-items: center; gap: 4px; cursor: pointer; color: #333;"> <input type="checkbox" id="text-bold" ${style.bold ? "checked" : ""}> <span style="font-weight: bold;">굵게</span> </label> <label style="display: flex; align-items: center; gap: 4px; cursor: pointer; color: #333;"> <input type="checkbox" id="text-italic" ${style.italic ? "checked" : ""}> <span style="font-style: italic;">기울임</span> </label> <label style="display: flex; align-items: center; gap: 4px; cursor: pointer; color: #333;"> <input type="checkbox" id="text-underline" ${style.underline ? "checked" : ""}> <span style="text-decoration: underline;">밑줄</span> </label> </div> </div> <div style="display: flex; justify-content: flex-end; gap: 8px;"> <button id="cancel-btn" style=" padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; background: white; cursor: pointer; " >취소</button> <button id="confirm-btn" style=" padding: 8px 16px; border: none; border-radius: 4px; background: #2196F3; color: white; cursor: pointer; " >확인</button> </div> `; backdrop.appendChild(this.modal); this.container.appendChild(backdrop); this.setupEventHandlers(backdrop); const textInput = this.modal.querySelector("#text-input"); if (textInput) { textInput.focus(); textInput.select(); } } setupEventHandlers(backdrop) { if (!this.modal) return; const useBackground = this.modal.querySelector("#use-background"); const backgroundColor = this.modal.querySelector("#background-color"); useBackground?.addEventListener("change", () => { backgroundColor.disabled = !useBackground.checked; }); const cancelBtn = this.modal.querySelector("#cancel-btn"); cancelBtn?.addEventListener("click", () => { this.close(backdrop); this.options?.onCancel(); }); const confirmBtn = this.modal.querySelector("#confirm-btn"); confirmBtn?.addEventListener("click", () => { this.handleConfirm(backdrop); }); const textInput = this.modal.querySelector("#text-input"); textInput?.addEventListener("keydown", (e) => { if (e.key === "Enter" && e.ctrlKey) { e.preventDefault(); this.handleConfirm(backdrop); } }); backdrop.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); this.close(backdrop); this.options?.onCancel(); } }); backdrop.addEventListener("click", (e) => { if (e.target === backdrop) { this.close(backdrop); this.options?.onCancel(); } }); } handleConfirm(backdrop) { if (!this.modal) return; const textInput = this.modal.querySelector("#text-input"); const text = textInput?.value || ""; if (!text.trim()) { alert("텍스트를 입력해주세요."); return; } const style = { fontSize: parseInt(this.modal.querySelector("#font-size")?.value || "14"), fontFamily: this.modal.querySelector("#font-family")?.value || "Arial, sans-serif", color: this.modal.querySelector("#text-color")?.value || "#000000", bold: this.modal.querySelector("#text-bold")?.checked || false, italic: this.modal.querySelector("#text-italic")?.checked || false, underline: this.modal.querySelector("#text-underline")?.checked || false }; const useBackground = this.modal.querySelector("#use-background")?.checked; if (useBackground) { style.backgroundColor = this.modal.querySelector("#background-color")?.value; } this.close(backdrop); this.options?.onConfirm(text, style); } close(backdrop) { backdrop.remove(); this.modal = null; } } class DrawingManager { snapManager; measureManager; objectManager; stateManager; toolHandler; theme = null; defaultStyle = {}; currentTool = null; textRenderer = null; textInputModal = null; container = null; coordSystem = null; textDefaults = null; textDebugLogged = false; chart = null; // Chart 타입을 사용하면 순환 참조가 발생하므로 any 사용 constructor() { this.snapManager = new DrawingSnapManager(); this.measureManager = new DrawingMeasureManager(); this.objectManager = new DrawingObjectManager(); this.stateManager = new DrawingStateManager(); this.toolHandler = new DrawingToolHandler(); this.saveState(); } // 컨테이너와 좌표계 설정 setContainerAndCoordSystem(container, coordSystem) { this.container = container; this.coordSystem = coordSystem; this.textRenderer = new TextRenderer(container, coordSystem); this.textInputModal = new TextInputModal(container); this.textRenderer.setStyleEditCallback((id, currentStyle) => { const textObject = this.objectManager.getAllDrawings().find((d) => d.id === id); if (!textObject || !this.textInputModal || !this.coordSystem) return; const point = textObject.points[0]; const canvasPoint = this.coordSystem.chartToCanvas(point.time, point.price); this.textInputModal.show({ position: { x: canvasPoint.x, y: canvasPoint.y }, initialText: textObject.text, initialStyle: textObject.textStyle || currentStyle, onConfirm: (newText, newStyle) => { textObject.text = newText; textObject.textStyle = newStyle; if (textObject.style) { textObject.style.color = newStyle.color; } this.saveState(); if (this.textRenderer) { this.textRenderer.renderText(textObject); } }, onCancel: () => { } }); }); this.toolHandler.setTextInputCallback((point, callback) => { if (!this.textInputModal || !this.coordSystem) { console.error("[DrawingManager] Missing textInputModal or coordSystem:", { textInputModal: !!this.textInputModal, coordSystem: !!this.coordSystem }); return; } const canvasPoint = this.coordSystem.chartToCanvas(point.time, point.price); const defaultStyle = this.textDefaults || { fontSize: 14, fontFamily: "Arial, sans-serif", color: this.getDrawingColor(), bold: false, italic: false, underline: false, padding: 4 }; this.textInputModal.show({ position: { x: canvasPoint.x, y: canvasPoint.y }, initialText: "", initialStyle: defaultStyle, onConfirm: (text, style) => { const textDrawing = this.objectManager.createDrawing("text", [point], { color: style.color, lineWidth: 1, opacity: 1 }); textDrawing.text = text; textDrawing.textStyle = style; this.objectManager.addDrawing(textDrawing); this.saveState(); if (this.textRenderer) { this.textRenderer.renderText(textDrawing); } callback(text); }, onCancel: () => { } }); }); } // ===== 테마 설정 ===== setTheme(theme) { this.theme = theme; } setChart(chart) { this.chart = chart; } // 기본 드로잉 스타일 설정 (Trading Journal 통합용) setDefaultStyle(style) { this.defaultStyle = { ...this.defaultStyle, ...style }; } // 텍스트 기본 스타일 설정 setTextDefaults(textDefaults) { this.textDefaults = textDefaults; } getDrawingColor() { if (this.defaultStyle.color) { return this.defaultStyle.color; } if (this.theme) { return this.theme.background === "#ffffff" || this.theme.background?.includes("255, 255, 255") ? "#000000" : "#FFFFFF"; } const bgColor = document.body.style.backgroundColor; const isLightTheme = bgColor === "rgb(255, 255, 255)" || bgColor === "#ffffff"; return isLightTheme ? "#000000" : "#FFFFFF"; } getMarkerColor() { if (this.theme) { return this.theme.background === "#ffffff" || this.theme.background?.includes("255, 255, 255") ? "#FFA500" : "#FFFF00"; } const bgColor = document.body.style.backgroundColor; const isLightTheme = bgColor === "rgb(255, 255, 255)" || bgColor === "#ffffff"; return isLightTheme ? "#FFA500" : "#FFFF00"; } // ===== 콜백 설정 ===== setSnapChangeCallback(callback) { this.snapManager.setSnapChangeCallback(callback); } setMeasureChangeCallback(callback) { this.measureManager.setMeasureChangeCallback(callback); } setTextInputCallback(callback) { this.toolHandler.setTextInputCallback(callback); } // ===== 도구 관리 ===== setCurrentTool(tool) { this.currentTool = tool; this.toolHandler.setCurrentTool(tool); if (this.measureManager.isMeasureMode()) { this.measureManager.cancelMeasure(); } } getCurrentTool() { return this.toolHandler.getCurrentTool(); } // ===== 마그네틱 모드 ===== setMagneticMode(enabled) { this.snapManager.setMagneticMode(enabled); } getMagneticMode() { return this.snapManager.getMagneticMode(); } // ===== 스냅 포인트 ===== findSnapPoint(mouseX, mouseY, candles, coordSystem, threshold = 20) { return this.snapManager.findSnapPoint(mouseX, mouseY, candles, coordSystem, threshold); } // ===== 마우스 이벤트 처리 ===== handleMouseMove(mouseX, mouseY, candles, coordSystem, getChartPoint) { if (this.snapManager.getMagneticMode()) { this.findSnapPoint(mouseX, mouseY, candles, coordSystem); } if (this.measureManager.isMeasureMode() && !this.measureManager.isMeasureFixed()) { const measurePoints = this.measureManager.getMeasurePoints(); if (measurePoints.start) { const snapPoint = this.snapManager.getCurrentSnapPoint(); const currentPoint = snapPoint ? { time: snapPoint.time, price: snapPoint.price } : getChartPoint(mouseX, mouseY); this.measureManager.updateMeasure(currentPoint); } } if (this.toolHandler.isCurrentlyDrawing()) { const snapPoint = this.snapManager.getCurrentSnapPoint(); const currentPoint = snapPoint ? { time: snapPoint.time, price: snapPoint.price } : getChartPoint(mouseX, mouseY); this.toolHandler.updateDrawing(currentPoint, coordSystem); } } handleClick(x, y, getChartPoint, coordSystem) { const snapPoint = this.snapManager.getCurrentSnapPoint(); const chartPoint = snapPoint ? { time: snapPoint.time, price: snapPoint.price } : getChartPoint(x, y); if (!this.toolHandler.getCurrentTool()) { if (this.measureManager.isMeasureMode()) { const measurePoints = this.measureManager.getMeasurePoints(); if (!measurePoints.start) { this.measureManager.startMeasure(chartPoint); } else if (!measurePoints.fixed) { this.measureManager.fixMeasure(chartPoint); } else { this.measureManager.cancelMeasure(); } return; } this.objectManager.selectDrawingAt(x, y, coordSystem); return; } const currentTool = this.toolHandler.getCurrentTool(); if (currentTool === "trendline" || currentTool === "circle" || currentTool === "rectangle") { if (!this.toolHandler.getFirstPoint()) { const drawing = this.toolHandler.startDrawing(chartPoint, { color: this.getDrawingColor(), lineWidth: this.defaultStyle.lineWidth || 2, opacity: this.defaultStyle.opacity || 1 }); if (drawing && currentTool === "circle") { drawing.radius = 0; } } else { const finishedDrawing = this.toolHandler.finishDrawing(); if (finishedDrawing) { const newDrawing = this.objectManager.createDrawing( finishedDrawing.type, finishedDrawing.points, finishedDrawing.style, finishedDrawing.type === "circle" ? { radius: finishedDrawing.radius } : void 0 ); this.objectManager.addDrawing(newDrawing); this.saveState(); } } } else if (currentTool === "horizontal") { this.createHorizontalLineImmediate(chartPoint); } else if (currentTool === "marker") { const markerColor = this.getMarkerColor(); const marker = this.objectManager.createDrawing("marker", [chartPoint], { color: markerColor, lineWidth: 2, opacity: 1 }, {}); this.objectManager.addDrawing(marker); this.saveState(); } else if (currentTool === "text") { this.toolHandler.requestTextInput(chartPoint, (text) => { }); } } // ===== 드로잉 작업 ===== cancelDrawing() { this.toolHandler.cancelDrawing(); if (this.measureManager.isMeasureMode()) { this.measureManager.cancelMeasure(); } } createHorizon