Chart: volume dots, price dots, multi-symbol volumes; FIX UI back button
Browse filesPrice chart:
- Volume bars narrowed to 25% slot width, capped at 5px
- Close-price overlay replaced with orange dots per candle (not a line)
- Legend updated to dot symbol instead of line
Multi-symbol chart:
- Volume data now passed from loadAllHistory (live + candle modes)
- drawMultiChart splits into 70% price / 30% volume when volumes present
- Aggregated volume bars (sum across symbols per 60s bucket) shown below
- Volume Y-axis labels on right side (0, 50%, max)
FIX UI:
- Added "← Dashboard" back button in header (links to /)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dashboard/templates/index.html
CHANGED
|
@@ -781,7 +781,7 @@
|
|
| 781 |
const n = points.length;
|
| 782 |
const slotW = W / n;
|
| 783 |
const bodyW = Math.max(1, Math.floor(slotW * 0.65));
|
| 784 |
-
const volBarW = Math.max(1, Math.floor(slotW * 0.
|
| 785 |
|
| 786 |
// Price grid
|
| 787 |
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
|
|
@@ -831,14 +831,14 @@
|
|
| 831 |
ctx.fillRect(x, bodyTop, bodyW, bodyH);
|
| 832 |
});
|
| 833 |
|
| 834 |
-
// Close-price
|
| 835 |
-
ctx.
|
| 836 |
-
ctx.beginPath();
|
| 837 |
points.forEach((p, i) => {
|
| 838 |
const x = pad.left + i * slotW + slotW / 2;
|
| 839 |
-
|
|
|
|
|
|
|
| 840 |
});
|
| 841 |
-
ctx.stroke();
|
| 842 |
|
| 843 |
// Time labels (up to 5 evenly spaced)
|
| 844 |
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "center";
|
|
@@ -856,8 +856,8 @@
|
|
| 856 |
ctx.fillStyle = "#26a69a"; ctx.fillText("▲", pad.left, 12);
|
| 857 |
ctx.fillStyle = "#ef5350"; ctx.fillText("▼", pad.left + 14, 12);
|
| 858 |
ctx.fillStyle = "#888"; ctx.fillText("candle", pad.left + 24, 12);
|
| 859 |
-
ctx.
|
| 860 |
-
ctx.beginPath(); ctx.
|
| 861 |
ctx.fillStyle = "#ff9800"; ctx.fillText("close", pad.left + 80, 12);
|
| 862 |
if (labelSuffix) { ctx.fillStyle = "#bbb"; ctx.fillText(labelSuffix, pad.left + 114, 12); }
|
| 863 |
}
|
|
@@ -1180,9 +1180,15 @@
|
|
| 1180 |
return;
|
| 1181 |
}
|
| 1182 |
|
| 1183 |
-
const
|
| 1184 |
-
const
|
| 1185 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1186 |
|
| 1187 |
// Normalise each series to % change from its first point
|
| 1188 |
const norm = active.map(s => {
|
|
@@ -1202,10 +1208,10 @@
|
|
| 1202 |
const maxTs = Math.max(...allTs);
|
| 1203 |
const tsRng = maxTs - minTs || 1;
|
| 1204 |
|
| 1205 |
-
const toX = ts => pad.left + ((ts
|
| 1206 |
-
const toY = pct =>
|
| 1207 |
|
| 1208 |
-
//
|
| 1209 |
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
|
| 1210 |
for (let i = 0; i <= 4; i++) {
|
| 1211 |
const pct = minPct + (maxPct - minPct) * i / 4;
|
|
@@ -1236,6 +1242,44 @@
|
|
| 1236 |
ctx.stroke();
|
| 1237 |
});
|
| 1238 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1239 |
// Time labels (up to 4)
|
| 1240 |
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "center";
|
| 1241 |
for (let i = 0; i <= 3; i++) {
|
|
@@ -1277,7 +1321,7 @@
|
|
| 1277 |
if (period === "live") {
|
| 1278 |
const series = symbols.map(sym => {
|
| 1279 |
let trades = state.trades.filter(t => t.symbol === sym).slice(0, 100).reverse();
|
| 1280 |
-
return { symbol: sym, points: trades.map(t => ({ price: t.price || 0, ts: t.timestamp || Date.now()/1000 })) };
|
| 1281 |
});
|
| 1282 |
drawMultiChart(series, `${symbols.length} symbols`);
|
| 1283 |
statusEl.textContent = `${symbols.length} symbols`;
|
|
@@ -1291,7 +1335,7 @@
|
|
| 1291 |
);
|
| 1292 |
const series = symbols.map((sym, i) => ({
|
| 1293 |
symbol: sym,
|
| 1294 |
-
points: (results[i].candles || []).map(c => ({ price: c.c, ts: c.t }))
|
| 1295 |
}));
|
| 1296 |
const filled = series.filter(s => s.points.length > 0);
|
| 1297 |
drawMultiChart(series, `${filled.length} symbols`);
|
|
|
|
| 781 |
const n = points.length;
|
| 782 |
const slotW = W / n;
|
| 783 |
const bodyW = Math.max(1, Math.floor(slotW * 0.65));
|
| 784 |
+
const volBarW = Math.max(1, Math.min(Math.floor(slotW * 0.25), 5));
|
| 785 |
|
| 786 |
// Price grid
|
| 787 |
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
|
|
|
|
| 831 |
ctx.fillRect(x, bodyTop, bodyW, bodyH);
|
| 832 |
});
|
| 833 |
|
| 834 |
+
// Close-price dots (orange) — one point per candle
|
| 835 |
+
ctx.fillStyle = "rgba(255,152,0,0.9)";
|
|
|
|
| 836 |
points.forEach((p, i) => {
|
| 837 |
const x = pad.left + i * slotW + slotW / 2;
|
| 838 |
+
ctx.beginPath();
|
| 839 |
+
ctx.arc(x, toY(p.close), 2, 0, Math.PI * 2);
|
| 840 |
+
ctx.fill();
|
| 841 |
});
|
|
|
|
| 842 |
|
| 843 |
// Time labels (up to 5 evenly spaced)
|
| 844 |
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "center";
|
|
|
|
| 856 |
ctx.fillStyle = "#26a69a"; ctx.fillText("▲", pad.left, 12);
|
| 857 |
ctx.fillStyle = "#ef5350"; ctx.fillText("▼", pad.left + 14, 12);
|
| 858 |
ctx.fillStyle = "#888"; ctx.fillText("candle", pad.left + 24, 12);
|
| 859 |
+
ctx.fillStyle = "rgba(255,152,0,0.9)";
|
| 860 |
+
ctx.beginPath(); ctx.arc(pad.left + 72, 9, 2.5, 0, Math.PI * 2); ctx.fill();
|
| 861 |
ctx.fillStyle = "#ff9800"; ctx.fillText("close", pad.left + 80, 12);
|
| 862 |
if (labelSuffix) { ctx.fillStyle = "#bbb"; ctx.fillText(labelSuffix, pad.left + 114, 12); }
|
| 863 |
}
|
|
|
|
| 1180 |
return;
|
| 1181 |
}
|
| 1182 |
|
| 1183 |
+
const hasVolume = active.some(s => s.points.some(p => (p.volume || 0) > 0));
|
| 1184 |
+
const pad = { top: 18, right: 48, bottom: 32, left: 46 };
|
| 1185 |
+
const W = canvas.width - pad.left - pad.right;
|
| 1186 |
+
const H = canvas.height - pad.top - pad.bottom;
|
| 1187 |
+
const priceH = hasVolume ? H * 0.70 : H;
|
| 1188 |
+
const gapH = hasVolume ? H * 0.03 : 0;
|
| 1189 |
+
const volH = hasVolume ? H * 0.27 : 0;
|
| 1190 |
+
const priceY = pad.top;
|
| 1191 |
+
const volY = pad.top + priceH + gapH;
|
| 1192 |
|
| 1193 |
// Normalise each series to % change from its first point
|
| 1194 |
const norm = active.map(s => {
|
|
|
|
| 1208 |
const maxTs = Math.max(...allTs);
|
| 1209 |
const tsRng = maxTs - minTs || 1;
|
| 1210 |
|
| 1211 |
+
const toX = ts => pad.left + ((ts - minTs) / tsRng) * W;
|
| 1212 |
+
const toY = pct => priceY + priceH - ((pct - minPct) / (maxPct - minPct)) * priceH;
|
| 1213 |
|
| 1214 |
+
// Price grid + Y labels
|
| 1215 |
ctx.strokeStyle = "#eee"; ctx.lineWidth = 1;
|
| 1216 |
for (let i = 0; i <= 4; i++) {
|
| 1217 |
const pct = minPct + (maxPct - minPct) * i / 4;
|
|
|
|
| 1242 |
ctx.stroke();
|
| 1243 |
});
|
| 1244 |
|
| 1245 |
+
// Volume section — aggregate all symbols by time bucket
|
| 1246 |
+
if (hasVolume) {
|
| 1247 |
+
const volMap = {};
|
| 1248 |
+
active.forEach(s => {
|
| 1249 |
+
s.points.forEach(p => {
|
| 1250 |
+
const bucket = Math.round(p.ts / 60) * 60;
|
| 1251 |
+
volMap[bucket] = (volMap[bucket] || 0) + (p.volume || 0);
|
| 1252 |
+
});
|
| 1253 |
+
});
|
| 1254 |
+
const volPoints = Object.entries(volMap)
|
| 1255 |
+
.map(([ts, vol]) => ({ ts: +ts, volume: vol }))
|
| 1256 |
+
.sort((a, b) => a.ts - b.ts);
|
| 1257 |
+
const maxVol = Math.max(...volPoints.map(v => v.volume), 1);
|
| 1258 |
+
|
| 1259 |
+
// Divider
|
| 1260 |
+
ctx.strokeStyle = "#e0e0e0"; ctx.lineWidth = 1;
|
| 1261 |
+
ctx.beginPath(); ctx.moveTo(pad.left, volY); ctx.lineTo(canvas.width - pad.right, volY); ctx.stroke();
|
| 1262 |
+
|
| 1263 |
+
// Bars
|
| 1264 |
+
const volSlotW = W / Math.max(volPoints.length, 1);
|
| 1265 |
+
const volBarW = Math.max(1, Math.min(Math.floor(volSlotW * 0.25), 5));
|
| 1266 |
+
volPoints.forEach(v => {
|
| 1267 |
+
const x = toX(v.ts) - volBarW / 2;
|
| 1268 |
+
const bh = (v.volume / maxVol) * volH;
|
| 1269 |
+
ctx.fillStyle = "rgba(96,125,139,0.5)";
|
| 1270 |
+
ctx.fillRect(x, volY + volH - bh, volBarW, bh);
|
| 1271 |
+
});
|
| 1272 |
+
|
| 1273 |
+
// Volume Y-axis labels (right side)
|
| 1274 |
+
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "left";
|
| 1275 |
+
[1.0, 0.5, 0.0].forEach(frac => {
|
| 1276 |
+
const val = Math.round(maxVol * frac);
|
| 1277 |
+
const y = volY + volH * (1 - frac);
|
| 1278 |
+
const lbl = val >= 1000 ? (val / 1000).toFixed(1) + "k" : val.toString();
|
| 1279 |
+
ctx.fillText(lbl, canvas.width - pad.right + 3, y + 3);
|
| 1280 |
+
});
|
| 1281 |
+
}
|
| 1282 |
+
|
| 1283 |
// Time labels (up to 4)
|
| 1284 |
ctx.fillStyle = "#888"; ctx.font = "9px Arial"; ctx.textAlign = "center";
|
| 1285 |
for (let i = 0; i <= 3; i++) {
|
|
|
|
| 1321 |
if (period === "live") {
|
| 1322 |
const series = symbols.map(sym => {
|
| 1323 |
let trades = state.trades.filter(t => t.symbol === sym).slice(0, 100).reverse();
|
| 1324 |
+
return { symbol: sym, points: trades.map(t => ({ price: t.price || 0, ts: t.timestamp || Date.now()/1000, volume: t.quantity || t.qty || 0 })) };
|
| 1325 |
});
|
| 1326 |
drawMultiChart(series, `${symbols.length} symbols`);
|
| 1327 |
statusEl.textContent = `${symbols.length} symbols`;
|
|
|
|
| 1335 |
);
|
| 1336 |
const series = symbols.map((sym, i) => ({
|
| 1337 |
symbol: sym,
|
| 1338 |
+
points: (results[i].candles || []).map(c => ({ price: c.c, ts: c.t, volume: c.v || 0 }))
|
| 1339 |
}));
|
| 1340 |
const filled = series.filter(s => s.points.length > 0);
|
| 1341 |
drawMultiChart(series, `${filled.length} symbols`);
|
fix-ui-client/templates/index.html
CHANGED
|
@@ -125,6 +125,7 @@
|
|
| 125 |
<span class="dot"></span>
|
| 126 |
<span>{{ 'CONNECTED' if connected else 'DISCONNECTED' }}</span>
|
| 127 |
</span>
|
|
|
|
| 128 |
</h1>
|
| 129 |
|
| 130 |
<div class="container">
|
|
|
|
| 125 |
<span class="dot"></span>
|
| 126 |
<span>{{ 'CONNECTED' if connected else 'DISCONNECTED' }}</span>
|
| 127 |
</span>
|
| 128 |
+
<a href="/" style="margin-left:auto; padding:4px 14px; background:#6c757d; color:#fff; border-radius:20px; font-size:12px; font-weight:bold; text-decoration:none;">← Dashboard</a>
|
| 129 |
</h1>
|
| 130 |
|
| 131 |
<div class="container">
|