feat: 선물 차트 마커 UI 개선 및 동일 타점 세로 스택

마커 라벨 2배 확대, Y축 뒤집기 버튼 추가, 같은 시각 마커를 좌우 대신 위아래로 표시한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-06-09 21:12:06 +09:00
parent a6e0d4081a
commit 281f4cfa25
23 changed files with 1603 additions and 1302 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -118,13 +119,16 @@
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const LABEL_GAP = 12;
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null;
@@ -199,7 +203,7 @@
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -224,16 +228,23 @@
ctx.fill();
}
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx;
const xOff = m.x_offset_px || 0;
const x = u.valToPos(m.time, "x", true) + xOff;
const x = u.valToPos(m.time, "x", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
const arrowUp = visualUp(up);
const stackIdx = m.stack_index || 0;
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
const anchorY = lineY + stackShift;
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
const labelY = arrowUp
? anchorY + ARROW_HEIGHT + LABEL_GAP
: anchorY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
}
@@ -257,13 +268,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const arrowUp = visualUp(false);
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = arrowUp
? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -313,8 +327,11 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
padding: [40, 10, 40, 10],
scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [
{ gap: 6 },
{
@@ -346,6 +363,7 @@
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
@@ -358,6 +376,7 @@
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
@@ -382,28 +401,42 @@
const t1 = DATA.times[end - 1];
const markers = [];
if (showMarkers) {
const add = (list, kind, up) => {
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time,
position: up ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: up ? "arrowUp" : "arrowDown",
size: 5,
text: markerLabel(kind, m),
});
});
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
add(DATA.long_open_markers, "longOpen", true);
add(DATA.long_close_markers, "longClose", false);
add(DATA.short_open_markers, "shortOpen", false);
add(DATA.short_close_markers, "shortClose", true);
const kindUp = {
longOpen: true,
longClose: false,
shortOpen: false,
shortClose: true,
};
const pending = [];
[
[DATA.long_open_markers, "longOpen"],
[DATA.long_close_markers, "longClose"],
[DATA.short_open_markers, "shortOpen"],
[DATA.short_close_markers, "shortClose"],
].forEach(([list, kind]) => {
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
});
});
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
pending.forEach(({ m, kind }) => {
const up = kindUp[kind];
const arrowUp = visualUp(up);
markers.push({
time: m.time,
position: arrowUp ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: arrowUp ? "arrowUp" : "arrowDown",
size: 10,
text: markerLabel(kind, m),
});
});
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
@@ -506,6 +539,15 @@
}
}
function toggleYFlip() {
yAxisInverted = !yAxisInverted;
const btn = document.getElementById("btn-flip-y");
btn.classList.toggle("active", yAxisInverted);
btn.textContent = yAxisInverted ? "↕ 복원" : "↕ 뒤집기";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart, lastDetailEnd || undefined);
}
function fmtMoney(v) {
return Math.round(v).toLocaleString("ko-KR") + "원";
@@ -622,6 +664,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -96,6 +96,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -118,13 +119,16 @@
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const LABEL_GAP = 12;
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null;
@@ -199,7 +203,7 @@
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -224,16 +228,23 @@
ctx.fill();
}
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx;
const xOff = m.x_offset_px || 0;
const x = u.valToPos(m.time, "x", true) + xOff;
const x = u.valToPos(m.time, "x", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
const arrowUp = visualUp(up);
const stackIdx = m.stack_index || 0;
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
const anchorY = lineY + stackShift;
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
const labelY = arrowUp
? anchorY + ARROW_HEIGHT + LABEL_GAP
: anchorY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
}
@@ -257,13 +268,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const arrowUp = visualUp(false);
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = arrowUp
? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -313,8 +327,11 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
padding: [40, 10, 40, 10],
scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [
{ gap: 6 },
{
@@ -346,6 +363,7 @@
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
@@ -358,6 +376,7 @@
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
@@ -382,28 +401,42 @@
const t1 = DATA.times[end - 1];
const markers = [];
if (showMarkers) {
const add = (list, kind, up) => {
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time,
position: up ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: up ? "arrowUp" : "arrowDown",
size: 5,
text: markerLabel(kind, m),
});
});
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
add(DATA.long_open_markers, "longOpen", true);
add(DATA.long_close_markers, "longClose", false);
add(DATA.short_open_markers, "shortOpen", false);
add(DATA.short_close_markers, "shortClose", true);
const kindUp = {
longOpen: true,
longClose: false,
shortOpen: false,
shortClose: true,
};
const pending = [];
[
[DATA.long_open_markers, "longOpen"],
[DATA.long_close_markers, "longClose"],
[DATA.short_open_markers, "shortOpen"],
[DATA.short_close_markers, "shortClose"],
].forEach(([list, kind]) => {
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
});
});
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
pending.forEach(({ m, kind }) => {
const up = kindUp[kind];
const arrowUp = visualUp(up);
markers.push({
time: m.time,
position: arrowUp ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: arrowUp ? "arrowUp" : "arrowDown",
size: 10,
text: markerLabel(kind, m),
});
});
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
@@ -506,6 +539,15 @@
}
}
function toggleYFlip() {
yAxisInverted = !yAxisInverted;
const btn = document.getElementById("btn-flip-y");
btn.classList.toggle("active", yAxisInverted);
btn.textContent = yAxisInverted ? "↕ 복원" : "↕ 뒤집기";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart, lastDetailEnd || undefined);
}
function fmtMoney(v) {
return Math.round(v).toLocaleString("ko-KR") + "원";
@@ -622,6 +664,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -96,6 +96,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -118,13 +119,16 @@
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const LABEL_GAP = 12;
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null;
@@ -199,7 +203,7 @@
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -224,16 +228,23 @@
ctx.fill();
}
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx;
const xOff = m.x_offset_px || 0;
const x = u.valToPos(m.time, "x", true) + xOff;
const x = u.valToPos(m.time, "x", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
const arrowUp = visualUp(up);
const stackIdx = m.stack_index || 0;
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
const anchorY = lineY + stackShift;
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
const labelY = arrowUp
? anchorY + ARROW_HEIGHT + LABEL_GAP
: anchorY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
}
@@ -257,13 +268,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const arrowUp = visualUp(false);
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = arrowUp
? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -313,8 +327,11 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
padding: [40, 10, 40, 10],
scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [
{ gap: 6 },
{
@@ -346,6 +363,7 @@
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
@@ -358,6 +376,7 @@
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
@@ -382,28 +401,42 @@
const t1 = DATA.times[end - 1];
const markers = [];
if (showMarkers) {
const add = (list, kind, up) => {
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time,
position: up ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: up ? "arrowUp" : "arrowDown",
size: 5,
text: markerLabel(kind, m),
});
});
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
add(DATA.long_open_markers, "longOpen", true);
add(DATA.long_close_markers, "longClose", false);
add(DATA.short_open_markers, "shortOpen", false);
add(DATA.short_close_markers, "shortClose", true);
const kindUp = {
longOpen: true,
longClose: false,
shortOpen: false,
shortClose: true,
};
const pending = [];
[
[DATA.long_open_markers, "longOpen"],
[DATA.long_close_markers, "longClose"],
[DATA.short_open_markers, "shortOpen"],
[DATA.short_close_markers, "shortClose"],
].forEach(([list, kind]) => {
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
});
});
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
pending.forEach(({ m, kind }) => {
const up = kindUp[kind];
const arrowUp = visualUp(up);
markers.push({
time: m.time,
position: arrowUp ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: arrowUp ? "arrowUp" : "arrowDown",
size: 10,
text: markerLabel(kind, m),
});
});
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
@@ -506,6 +539,15 @@
}
}
function toggleYFlip() {
yAxisInverted = !yAxisInverted;
const btn = document.getElementById("btn-flip-y");
btn.classList.toggle("active", yAxisInverted);
btn.textContent = yAxisInverted ? "↕ 복원" : "↕ 뒤집기";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart, lastDetailEnd || undefined);
}
function fmtMoney(v) {
return Math.round(v).toLocaleString("ko-KR") + "원";
@@ -622,6 +664,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -58,6 +58,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -80,13 +81,16 @@
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const LABEL_GAP = 12;
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null;
@@ -161,7 +165,7 @@
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -186,16 +190,23 @@
ctx.fill();
}
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx;
const xOff = m.x_offset_px || 0;
const x = u.valToPos(m.time, "x", true) + xOff;
const x = u.valToPos(m.time, "x", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
const arrowUp = visualUp(up);
const stackIdx = m.stack_index || 0;
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
const anchorY = lineY + stackShift;
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
const labelY = arrowUp
? anchorY + ARROW_HEIGHT + LABEL_GAP
: anchorY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
}
@@ -219,13 +230,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const arrowUp = visualUp(false);
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = arrowUp
? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -275,8 +289,11 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
padding: [40, 10, 40, 10],
scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [
{ gap: 6 },
{
@@ -308,6 +325,7 @@
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
@@ -320,6 +338,7 @@
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
@@ -344,28 +363,42 @@
const t1 = DATA.times[end - 1];
const markers = [];
if (showMarkers) {
const add = (list, kind, up) => {
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time,
position: up ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: up ? "arrowUp" : "arrowDown",
size: 5,
text: markerLabel(kind, m),
});
});
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
add(DATA.long_open_markers, "longOpen", true);
add(DATA.long_close_markers, "longClose", false);
add(DATA.short_open_markers, "shortOpen", false);
add(DATA.short_close_markers, "shortClose", true);
const kindUp = {
longOpen: true,
longClose: false,
shortOpen: false,
shortClose: true,
};
const pending = [];
[
[DATA.long_open_markers, "longOpen"],
[DATA.long_close_markers, "longClose"],
[DATA.short_open_markers, "shortOpen"],
[DATA.short_close_markers, "shortClose"],
].forEach(([list, kind]) => {
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
});
});
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
pending.forEach(({ m, kind }) => {
const up = kindUp[kind];
const arrowUp = visualUp(up);
markers.push({
time: m.time,
position: arrowUp ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: arrowUp ? "arrowUp" : "arrowDown",
size: 10,
text: markerLabel(kind, m),
});
});
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
@@ -468,6 +501,15 @@
}
}
function toggleYFlip() {
yAxisInverted = !yAxisInverted;
const btn = document.getElementById("btn-flip-y");
btn.classList.toggle("active", yAxisInverted);
btn.textContent = yAxisInverted ? "↕ 복원" : "↕ 뒤집기";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart, lastDetailEnd || undefined);
}
function renderLegend() {
@@ -528,6 +570,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -58,6 +58,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -80,13 +81,16 @@
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const LABEL_GAP = 12;
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null;
@@ -161,7 +165,7 @@
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -186,16 +190,23 @@
ctx.fill();
}
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx;
const xOff = m.x_offset_px || 0;
const x = u.valToPos(m.time, "x", true) + xOff;
const x = u.valToPos(m.time, "x", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
const arrowUp = visualUp(up);
const stackIdx = m.stack_index || 0;
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
const anchorY = lineY + stackShift;
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
const labelY = arrowUp
? anchorY + ARROW_HEIGHT + LABEL_GAP
: anchorY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
}
@@ -219,13 +230,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const arrowUp = visualUp(false);
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = arrowUp
? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -275,8 +289,11 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
padding: [40, 10, 40, 10],
scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [
{ gap: 6 },
{
@@ -308,6 +325,7 @@
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
@@ -320,6 +338,7 @@
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
@@ -344,28 +363,42 @@
const t1 = DATA.times[end - 1];
const markers = [];
if (showMarkers) {
const add = (list, kind, up) => {
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time,
position: up ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: up ? "arrowUp" : "arrowDown",
size: 5,
text: markerLabel(kind, m),
});
});
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
add(DATA.long_open_markers, "longOpen", true);
add(DATA.long_close_markers, "longClose", false);
add(DATA.short_open_markers, "shortOpen", false);
add(DATA.short_close_markers, "shortClose", true);
const kindUp = {
longOpen: true,
longClose: false,
shortOpen: false,
shortClose: true,
};
const pending = [];
[
[DATA.long_open_markers, "longOpen"],
[DATA.long_close_markers, "longClose"],
[DATA.short_open_markers, "shortOpen"],
[DATA.short_close_markers, "shortClose"],
].forEach(([list, kind]) => {
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
});
});
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
pending.forEach(({ m, kind }) => {
const up = kindUp[kind];
const arrowUp = visualUp(up);
markers.push({
time: m.time,
position: arrowUp ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: arrowUp ? "arrowUp" : "arrowDown",
size: 10,
text: markerLabel(kind, m),
});
});
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
@@ -468,6 +501,15 @@
}
}
function toggleYFlip() {
yAxisInverted = !yAxisInverted;
const btn = document.getElementById("btn-flip-y");
btn.classList.toggle("active", yAxisInverted);
btn.textContent = yAxisInverted ? "↕ 복원" : "↕ 뒤집기";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart, lastDetailEnd || undefined);
}
function renderLegend() {
@@ -528,6 +570,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -58,6 +58,7 @@
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -80,13 +81,16 @@
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const LABEL_GAP = 12;
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null;
@@ -161,7 +165,7 @@
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -186,16 +190,23 @@
ctx.fill();
}
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx;
const xOff = m.x_offset_px || 0;
const x = u.valToPos(m.time, "x", true) + xOff;
const x = u.valToPos(m.time, "x", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
const arrowUp = visualUp(up);
const stackIdx = m.stack_index || 0;
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
const anchorY = lineY + stackShift;
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
const labelY = arrowUp
? anchorY + ARROW_HEIGHT + LABEL_GAP
: anchorY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
}
@@ -219,13 +230,16 @@
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const arrowUp = visualUp(false);
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = arrowUp
? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -275,8 +289,11 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
padding: [40, 10, 40, 10],
scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [
{ gap: 6 },
{
@@ -308,6 +325,7 @@
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
@@ -320,6 +338,7 @@
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
@@ -344,28 +363,42 @@
const t1 = DATA.times[end - 1];
const markers = [];
if (showMarkers) {
const add = (list, kind, up) => {
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time,
position: up ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: up ? "arrowUp" : "arrowDown",
size: 5,
text: markerLabel(kind, m),
});
});
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
add(DATA.long_open_markers, "longOpen", true);
add(DATA.long_close_markers, "longClose", false);
add(DATA.short_open_markers, "shortOpen", false);
add(DATA.short_close_markers, "shortClose", true);
const kindUp = {
longOpen: true,
longClose: false,
shortOpen: false,
shortClose: true,
};
const pending = [];
[
[DATA.long_open_markers, "longOpen"],
[DATA.long_close_markers, "longClose"],
[DATA.short_open_markers, "shortOpen"],
[DATA.short_close_markers, "shortClose"],
].forEach(([list, kind]) => {
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
});
});
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
pending.forEach(({ m, kind }) => {
const up = kindUp[kind];
const arrowUp = visualUp(up);
markers.push({
time: m.time,
position: arrowUp ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: arrowUp ? "arrowUp" : "arrowDown",
size: 10,
text: markerLabel(kind, m),
});
});
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
@@ -468,6 +501,15 @@
}
}
function toggleYFlip() {
yAxisInverted = !yAxisInverted;
const btn = document.getElementById("btn-flip-y");
btn.classList.toggle("active", yAxisInverted);
btn.textContent = yAxisInverted ? "↕ 복원" : "↕ 뒤집기";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart, lastDetailEnd || undefined);
}
function renderLegend() {
@@ -528,6 +570,7 @@
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";

File diff suppressed because one or more lines are too long

View File

@@ -157,11 +157,11 @@
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
@@ -186,7 +186,7 @@
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -223,8 +223,8 @@
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
const labelY = lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -234,7 +234,7 @@
ctx.textBaseline = "alphabetic";
}
const LABEL_GAP = 12;
const LABEL_GAP = 24;
function drawMarkers(u, buys, sells) {
if (!showMarkers) return;
@@ -295,7 +295,7 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
padding: [40, 10, 40, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
@@ -375,7 +375,7 @@
const sm = DATA.sim_start_marker;
if (sm.time >= t0 && sm.time <= t1) markers.push({
time: sm.time, position: "aboveBar",
color: SIM_START_COLOR, shape: "arrowDown", size: 3,
color: SIM_START_COLOR, shape: "arrowDown", size: 6,
text: sm.label || "거래시작",
});
}
@@ -385,7 +385,7 @@
time: m.time, position: "belowBar",
color: m.signal_type === "breakout" ? "#ef6c00"
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
shape: "arrowUp", size: 5,
shape: "arrowUp", size: 10,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -393,7 +393,7 @@
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "aboveBar",
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
shape: "arrowDown", size: 5,
shape: "arrowDown", size: 10,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});

File diff suppressed because one or more lines are too long

View File

@@ -157,11 +157,11 @@
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
@@ -186,7 +186,7 @@
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -223,8 +223,8 @@
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
const labelY = lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -234,7 +234,7 @@
ctx.textBaseline = "alphabetic";
}
const LABEL_GAP = 12;
const LABEL_GAP = 24;
function drawMarkers(u, buys, sells) {
if (!showMarkers) return;
@@ -295,7 +295,7 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
padding: [40, 10, 40, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
@@ -375,7 +375,7 @@
const sm = DATA.sim_start_marker;
if (sm.time >= t0 && sm.time <= t1) markers.push({
time: sm.time, position: "aboveBar",
color: SIM_START_COLOR, shape: "arrowDown", size: 3,
color: SIM_START_COLOR, shape: "arrowDown", size: 6,
text: sm.label || "거래시작",
});
}
@@ -385,7 +385,7 @@
time: m.time, position: "belowBar",
color: m.signal_type === "breakout" ? "#ef6c00"
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
shape: "arrowUp", size: 5,
shape: "arrowUp", size: 10,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -393,7 +393,7 @@
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "aboveBar",
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
shape: "arrowDown", size: 5,
shape: "arrowDown", size: 10,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});

File diff suppressed because one or more lines are too long

View File

@@ -157,11 +157,11 @@
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
@@ -186,7 +186,7 @@
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -223,8 +223,8 @@
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
const labelY = lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -234,7 +234,7 @@
ctx.textBaseline = "alphabetic";
}
const LABEL_GAP = 12;
const LABEL_GAP = 24;
function drawMarkers(u, buys, sells) {
if (!showMarkers) return;
@@ -295,7 +295,7 @@
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
padding: [40, 10, 40, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
@@ -375,7 +375,7 @@
const sm = DATA.sim_start_marker;
if (sm.time >= t0 && sm.time <= t1) markers.push({
time: sm.time, position: "aboveBar",
color: SIM_START_COLOR, shape: "arrowDown", size: 3,
color: SIM_START_COLOR, shape: "arrowDown", size: 6,
text: sm.label || "거래시작",
});
}
@@ -385,7 +385,7 @@
time: m.time, position: "belowBar",
color: m.signal_type === "breakout" ? "#ef6c00"
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
shape: "arrowUp", size: 5,
shape: "arrowUp", size: 10,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -393,7 +393,7 @@
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "aboveBar",
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
shape: "arrowDown", size: 5,
shape: "arrowDown", size: 10,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});

File diff suppressed because one or more lines are too long

View File

@@ -371,11 +371,11 @@ __EXTRA_BODY__
el.textContent = `타점 ${currentLegIdx + 1} / ${total}`;
}
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const SIM_START_COLOR = "#7b1fa2";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
function markerSuffix(signalType) {
if (signalType === "pullback") return "*";
@@ -400,7 +400,7 @@ __EXTRA_BODY__
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -437,8 +437,8 @@ __EXTRA_BODY__
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
const labelY = lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -448,7 +448,7 @@ __EXTRA_BODY__
ctx.textBaseline = "alphabetic";
}
const LABEL_GAP = 12;
const LABEL_GAP = 24;
function drawMarkers(u, buys, sells) {
if (!showMarkers) return;
@@ -509,7 +509,7 @@ __EXTRA_BODY__
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
padding: [40, 10, 40, 10],
scales: { x: { time: true } },
axes: [
{ gap: 6 },
@@ -589,7 +589,7 @@ __EXTRA_BODY__
const sm = DATA.sim_start_marker;
if (sm.time >= t0 && sm.time <= t1) markers.push({
time: sm.time, position: "aboveBar",
color: SIM_START_COLOR, shape: "arrowDown", size: 3,
color: SIM_START_COLOR, shape: "arrowDown", size: 6,
text: sm.label || "거래시작",
});
}
@@ -599,7 +599,7 @@ __EXTRA_BODY__
time: m.time, position: "belowBar",
color: m.signal_type === "breakout" ? "#ef6c00"
: m.signal_type === "div_bull" ? "#7b1fa2" : "#2e7d32",
shape: "arrowUp", size: 5,
shape: "arrowUp", size: 10,
text: "B" + m.marker_id + markerSuffix(m.signal_type),
});
});
@@ -607,7 +607,7 @@ __EXTRA_BODY__
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time, position: "aboveBar",
color: m.signal_type === "div_bear" ? "#7b1fa2" : "#c62828",
shape: "arrowDown", size: 5,
shape: "arrowDown", size: 10,
text: "S" + m.marker_id + markerSuffix(m.signal_type),
});
});

View File

@@ -20,16 +20,15 @@ from deepcoin.ground_truth.futures import (
)
def _stagger_marker_positions(
def _stack_marker_positions(
long_open: list[dict[str, Any]],
long_close: list[dict[str, Any]],
short_open: list[dict[str, Any]],
short_close: list[dict[str, Any]],
stagger_px: int = 18,
) -> None:
"""동일 시각에 겹치는 마커에 수평 오프셋을 부여한다.
"""동일 시각에 겹치는 마커에 세로 스택 인덱스를 부여한다.
GT 매도 봉에서 L↓(롱 청산)과 S↓(숏 진입)이 같은 좌표에 겹치는 문제를 방지한다.
같은 봉의 L↓·S↓ 등이 좌우로 벌어지지 않고 동일 X에서 위아래로 쌓이도록 한다.
"""
from collections import defaultdict
@@ -47,13 +46,15 @@ def _stagger_marker_positions(
for markers_at_time in groups.values():
if len(markers_at_time) <= 1:
for marker in markers_at_time:
marker["stack_index"] = 0
continue
markers_at_time.sort(
key=lambda m: kind_rank.get(f"{m.get('position')}_{m.get('action')}", 9)
)
count = len(markers_at_time)
for index, marker in enumerate(markers_at_time):
marker["x_offset_px"] = int((index - (count - 1) / 2) * stagger_px)
marker["stack_index"] = index
marker.pop("x_offset_px", None)
_FUTURES_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ko">
@@ -115,6 +116,7 @@ __EXTRA_BODY__
<button id="btn-zoom-in" title="확대">+ 확대</button>
<button id="btn-zoom-out" title="축소"> 축소</button>
<button id="btn-fit" title="현재 뷰 맞춤">맞춤</button>
<button id="btn-flip-y" title="가격 축 위·아래 반전">↕ 뒤집기</button>
</div>
<div class="toolbar-group">
<button id="btn-markers" title="마커 표시/숨김">마커 숨김</button>
@@ -137,13 +139,16 @@ __EXTRA_BODY__
let showMarkers = true;
let detailVisible = false;
let lastDetailStart = 0;
let lastDetailEnd = 0;
let yAxisInverted = false;
const AXIS_FONT = "12px Malgun Gothic, Arial, sans-serif";
const MARKER_FONT = "bold 12px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 6;
const ARROW_HEIGHT = 8;
const LABEL_OFFSET_X = 8;
const LABEL_GAP = 12;
const MARKER_FONT = "bold 24px Malgun Gothic, Arial, sans-serif";
const ARROW_HALF = 12;
const ARROW_HEIGHT = 16;
const LABEL_OFFSET_X = 16;
const LABEL_GAP = 24;
const STACK_STEP = ARROW_HEIGHT + LABEL_GAP + 28;
const SIM_START_COLOR = "#7b1fa2";
let axisMeasureCtx = null;
@@ -218,7 +223,7 @@ __EXTRA_BODY__
ctx.font = MARKER_FONT;
const lx = x + LABEL_OFFSET_X;
ctx.textBaseline = "middle";
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, lx, labelY);
@@ -227,6 +232,10 @@ __EXTRA_BODY__
ctx.textBaseline = "alphabetic";
}
function visualUp(up) {
return yAxisInverted ? !up : up;
}
function drawTriangleOnLine(ctx, x, lineY, up, color) {
ctx.fillStyle = color;
ctx.beginPath();
@@ -245,14 +254,17 @@ __EXTRA_BODY__
function drawOneMarker(u, m, color, up, label) {
const ctx = u.ctx;
const xOff = m.x_offset_px || 0;
const x = u.valToPos(m.time, "x", true) + xOff;
const x = u.valToPos(m.time, "x", true);
const lineY = u.valToPos(markerChartPrice(m), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
drawTriangleOnLine(ctx, x, lineY, up, color);
const labelY = up
? lineY + ARROW_HEIGHT + LABEL_GAP
: lineY - ARROW_HEIGHT - LABEL_GAP;
const arrowUp = visualUp(up);
const stackIdx = m.stack_index || 0;
const stackShift = stackIdx * STACK_STEP * (arrowUp ? 1 : -1);
const anchorY = lineY + stackShift;
drawTriangleOnLine(ctx, x, anchorY, arrowUp, color);
const labelY = arrowUp
? anchorY + ARROW_HEIGHT + LABEL_GAP
: anchorY - ARROW_HEIGHT - LABEL_GAP;
drawMarkerLabel(ctx, label, x, labelY, color);
}
@@ -276,13 +288,16 @@ __EXTRA_BODY__
const lineY = u.valToPos(markerChartPrice(marker), "y", true);
if (x < u.bbox.left || x > u.bbox.left + u.bbox.width) return;
const color = SIM_START_COLOR;
drawTriangleOnLine(ctx, x, lineY, false, color);
const arrowUp = visualUp(false);
drawTriangleOnLine(ctx, x, lineY, arrowUp, color);
const label = marker.label || "거래시작";
ctx.font = MARKER_FONT;
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const labelY = lineY - ARROW_HEIGHT - 6;
ctx.lineWidth = 2;
ctx.textBaseline = arrowUp ? "top" : "bottom";
const labelY = arrowUp
? lineY + ARROW_HEIGHT + 12
: lineY - ARROW_HEIGHT - 12;
ctx.lineWidth = 4;
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(255,255,255,0.95)";
ctx.strokeText(label, x, labelY);
@@ -332,8 +347,11 @@ __EXTRA_BODY__
const opts = {
width: document.getElementById("overview").clientWidth,
height: 480,
padding: [28, 10, 28, 10],
scales: { x: { time: true } },
padding: [40, 10, 40, 10],
scales: {
x: { time: true },
y: { dir: yAxisInverted ? -1 : 1 },
},
axes: [
{ gap: 6 },
{
@@ -365,6 +383,7 @@ __EXTRA_BODY__
function buildDetailCandles(startIdx, endIdx) {
lastDetailStart = startIdx;
const end = endIdx || DATA.times.length;
lastDetailEnd = end;
document.getElementById("detail-wrap").style.display = detailVisible ? "block" : "none";
const wrap = document.getElementById("detail");
wrap.innerHTML = "";
@@ -377,6 +396,7 @@ __EXTRA_BODY__
borderVisible: true,
minimumWidth: priceAxisW,
scaleMargins: { top: 0.08, bottom: 0.08 },
invertScale: yAxisInverted,
},
timeScale: { timeVisible: true, secondsVisible: false },
width: wrap.clientWidth,
@@ -401,28 +421,41 @@ __EXTRA_BODY__
const t1 = DATA.times[end - 1];
const markers = [];
if (showMarkers) {
const add = (list, kind, up) => {
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) markers.push({
time: m.time,
position: up ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: up ? "arrowUp" : "arrowDown",
size: 5,
text: markerLabel(kind, m),
});
});
const colorMap = {
longOpen: COLORS.longOpen,
longClose: COLORS.longClose,
shortOpen: COLORS.shortOpen,
shortClose: COLORS.shortClose,
};
add(DATA.long_open_markers, "longOpen", true);
add(DATA.long_close_markers, "longClose", false);
add(DATA.short_open_markers, "shortOpen", false);
add(DATA.short_close_markers, "shortClose", true);
const kindUp = {
longOpen: true,
longClose: false,
shortOpen: false,
shortClose: true,
};
const pending = [];
[
[DATA.long_open_markers, "longOpen"],
[DATA.long_close_markers, "longClose"],
[DATA.short_open_markers, "shortOpen"],
[DATA.short_close_markers, "shortClose"],
].forEach(([list, kind]) => {
(list || []).forEach(m => {
if (m.time >= t0 && m.time <= t1) pending.push({ m, kind });
});
});
pending.sort((a, b) => a.m.time - b.m.time || (a.m.stack_index || 0) - (b.m.stack_index || 0));
pending.forEach(({ m, kind }) => {
const arrowUp = visualUp(kindUp[kind]);
markers.push({
time: m.time,
position: arrowUp ? "belowBar" : "aboveBar",
color: colorMap[kind],
shape: arrowUp ? "arrowUp" : "arrowDown",
size: 10,
text: markerLabel(kind, m),
});
});
}
markers.sort((a, b) => a.time - b.time);
detailSeries.setMarkers(markers);
@@ -525,6 +558,15 @@ __EXTRA_BODY__
}
}
function toggleYFlip() {
yAxisInverted = !yAxisInverted;
const btn = document.getElementById("btn-flip-y");
btn.classList.toggle("active", yAxisInverted);
btn.textContent = yAxisInverted ? "↕ 복원" : "↕ 뒤집기";
if (overviewPlot) buildOverview(true);
if (detailChart) buildDetailCandles(lastDetailStart, lastDetailEnd || undefined);
}
__EXTRA_SCRIPT__
function renderLegend() {
@@ -585,6 +627,7 @@ __EXTRA_SCRIPT__
document.getElementById("btn-zoom-in").onclick = () => applyZoom(0.6);
document.getElementById("btn-zoom-out").onclick = () => applyZoom(1.4);
document.getElementById("btn-fit").onclick = applyFit;
document.getElementById("btn-flip-y").onclick = toggleYFlip;
document.getElementById("btn-markers").onclick = () => {
showMarkers = !showMarkers;
document.getElementById("btn-markers").textContent = showMarkers ? "마커 숨김" : "마커 표시";
@@ -646,7 +689,7 @@ def _build_futures_chart_payload(
"meta": chart_meta,
"bar_count": len(df),
}
_stagger_marker_positions(
_stack_marker_positions(
payload["long_open_markers"],
payload["long_close_markers"],
payload["short_open_markers"],
@@ -857,7 +900,7 @@ def _build_futures_sim_chart_payload(
start_marker = _sim_start_marker(df, sim_pnl)
if start_marker is not None:
payload["sim_start_marker"] = start_marker
_stagger_marker_positions(
_stack_marker_positions(
payload["long_open_markers"],
payload["long_close_markers"],
payload["short_open_markers"],