chart-0714
Version:
Professional trading chart library with advanced customization for trading journal apps
1,598 lines (1,586 loc) • 394 kB
JavaScript
"use strict";
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const require$$0 = require("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();
}
}