emberwick 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -733,6 +733,332 @@ function drawCrosshair(ctx, s) {
733
733
  ctx.fillText(t, bx, plot.h + 12);
734
734
  }
735
735
  }
736
+ const MARKER_SHAPES = [
737
+ "arrowUp",
738
+ "arrowDown",
739
+ "triangleUp",
740
+ "triangleDown",
741
+ "circle",
742
+ "square",
743
+ "diamond",
744
+ "flag",
745
+ "label"
746
+ ];
747
+ const SHAPE_SET = new Set(MARKER_SHAPES);
748
+ const DEFAULT_POSITION = {
749
+ arrowUp: "belowBar",
750
+ triangleUp: "belowBar",
751
+ arrowDown: "aboveBar",
752
+ triangleDown: "aboveBar"
753
+ };
754
+ const POSITIONS = /* @__PURE__ */ new Set(["aboveBar", "belowBar", "inBar", "atPrice"]);
755
+ function normalizeMarker(raw, i) {
756
+ if (!raw || !isFinite(raw.time)) return null;
757
+ const shape = SHAPE_SET.has(raw.shape) ? raw.shape : "circle";
758
+ const position = POSITIONS.has(raw.position) ? raw.position : DEFAULT_POSITION[shape] || "aboveBar";
759
+ return {
760
+ id: raw.id != null ? String(raw.id) : `mk${i}`,
761
+ time: +raw.time,
762
+ price: isFinite(raw.price) ? +raw.price : null,
763
+ shape,
764
+ position,
765
+ color: raw.color || null,
766
+ textColor: raw.textColor || null,
767
+ text: raw.text != null ? String(raw.text) : "",
768
+ size: isFinite(raw.size) && raw.size > 0 ? +raw.size : 1,
769
+ /** Anything the consumer wants handed back on hover/click. */
770
+ data: raw.data,
771
+ index: -1
772
+ };
773
+ }
774
+ function normalizeMarkers(list) {
775
+ if (!Array.isArray(list)) return [];
776
+ const out = [];
777
+ for (let i = 0; i < list.length; i++) {
778
+ const m = normalizeMarker(list[i], i);
779
+ if (m) out.push(m);
780
+ }
781
+ out.sort((a, b) => a.time - b.time);
782
+ return out;
783
+ }
784
+ function nearestIndex(bars, time) {
785
+ const n = bars.length;
786
+ if (!n) return -1;
787
+ if (time <= bars[0].time) return 0;
788
+ if (time >= bars[n - 1].time) return n - 1;
789
+ let lo = 0;
790
+ let hi = n - 1;
791
+ while (lo <= hi) {
792
+ const mid = lo + hi >> 1;
793
+ const t = bars[mid].time;
794
+ if (t === time) return mid;
795
+ if (t < time) lo = mid + 1;
796
+ else hi = mid - 1;
797
+ }
798
+ const a = Math.max(0, hi);
799
+ const b = Math.min(n - 1, lo);
800
+ return Math.abs(bars[a].time - time) <= Math.abs(bars[b].time - time) ? a : b;
801
+ }
802
+ function resolveMarkers(markers, bars) {
803
+ for (let i = 0; i < markers.length; i++) {
804
+ markers[i].index = nearestIndex(bars, markers[i].time);
805
+ }
806
+ return markers;
807
+ }
808
+ function layoutMarkers(markers, s) {
809
+ const { ts, ps, plot, bars, live } = s;
810
+ if (!markers.length || !bars.length) return [];
811
+ const { from, to } = ts.visibleRange();
812
+ const lastIdx = bars.length - 1;
813
+ const dense = ts.barWidth() <= 3;
814
+ const stacks = /* @__PURE__ */ new Map();
815
+ const placed = [];
816
+ let lastDenseX = -Infinity;
817
+ for (const m of markers) {
818
+ const i = m.index;
819
+ if (i < 0 || i < from - 2 || i > to + 2) continue;
820
+ const bar = live && i === lastIdx ? live : bars[i];
821
+ if (!bar) continue;
822
+ const x = ts.x(i);
823
+ if (x < -48 || x > plot.w + 48) continue;
824
+ if (dense) {
825
+ if (x - lastDenseX < 4) continue;
826
+ lastDenseX = x;
827
+ }
828
+ const r = 5 * m.size;
829
+ let y;
830
+ let dir = 0;
831
+ if (m.position === "atPrice" && m.price != null) {
832
+ y = ps.y(m.price);
833
+ } else if (m.position === "inBar") {
834
+ y = ps.y((bar.high + bar.low) / 2);
835
+ } else if (m.position === "belowBar") {
836
+ y = ps.y(bar.low) + r + 7;
837
+ dir = 1;
838
+ } else {
839
+ y = ps.y(bar.high) - r - 7;
840
+ dir = -1;
841
+ }
842
+ if (dir !== 0) {
843
+ const key = i + m.position;
844
+ const n = stacks.get(key) || 0;
845
+ stacks.set(key, n + 1);
846
+ y += dir * n * (r * 2 + 5);
847
+ }
848
+ placed.push({ m, x, y, r, dir });
849
+ }
850
+ return placed;
851
+ }
852
+ const DASH = { solid: [], dashed: [6, 4], dotted: [1, 3] };
853
+ const DOWN_SHAPES = /* @__PURE__ */ new Set(["arrowDown", "triangleDown"]);
854
+ function roundRect(ctx, x, y, w, h, r) {
855
+ const rr = Math.max(0, Math.min(r, h / 2, w / 2));
856
+ ctx.beginPath();
857
+ ctx.moveTo(x + rr, y);
858
+ ctx.lineTo(x + w - rr, y);
859
+ ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
860
+ ctx.lineTo(x + w, y + h - rr);
861
+ ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
862
+ ctx.lineTo(x + rr, y + h);
863
+ ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
864
+ ctx.lineTo(x, y + rr);
865
+ ctx.quadraticCurveTo(x, y, x + rr, y);
866
+ ctx.closePath();
867
+ }
868
+ function drawZones(ctx, s) {
869
+ const { zones, theme, ts, ps, plot, bars } = s;
870
+ if (!zones || !zones.length) return;
871
+ ctx.save();
872
+ ctx.font = theme.font;
873
+ ctx.textBaseline = "top";
874
+ ctx.textAlign = "left";
875
+ for (const z of zones) {
876
+ let x;
877
+ let y;
878
+ let w;
879
+ let h;
880
+ if (isFinite(z.from) && isFinite(z.to)) {
881
+ y = ps.y(Math.max(z.from, z.to));
882
+ h = Math.max(1, ps.y(Math.min(z.from, z.to)) - y);
883
+ x = 0;
884
+ w = plot.w;
885
+ } else if (isFinite(z.fromTime) && isFinite(z.toTime) && bars.length) {
886
+ const a = ts.x(nearestIndex(bars, Math.min(z.fromTime, z.toTime)));
887
+ const b = ts.x(nearestIndex(bars, Math.max(z.fromTime, z.toTime)));
888
+ x = a;
889
+ w = Math.max(1, b - a);
890
+ y = 0;
891
+ h = plot.h;
892
+ } else {
893
+ continue;
894
+ }
895
+ if (x > plot.w || x + w < 0 || y > plot.h || y + h < 0) continue;
896
+ ctx.fillStyle = z.color || "rgba(38,166,154,0.10)";
897
+ ctx.fillRect(x, y, w, h);
898
+ if (z.border) {
899
+ ctx.strokeStyle = z.border;
900
+ ctx.lineWidth = 1;
901
+ ctx.strokeRect(Math.round(x) + 0.5, Math.round(y) + 0.5, Math.round(w), Math.round(h));
902
+ }
903
+ if (z.label) {
904
+ ctx.fillStyle = z.labelColor || theme.text;
905
+ ctx.fillText(z.label, Math.max(6, x + 6), Math.max(4, y + 4));
906
+ }
907
+ }
908
+ ctx.restore();
909
+ }
910
+ function drawPriceLines(ctx, s) {
911
+ const { priceLines, theme, ps, plot } = s;
912
+ if (!priceLines || !priceLines.length) return;
913
+ const { step } = priceTicks(ps.lo, ps.hi, Math.max(2, Math.floor(plot.h / 58)));
914
+ const dec = decimalsFor(step);
915
+ ctx.save();
916
+ ctx.font = theme.font;
917
+ ctx.textBaseline = "middle";
918
+ for (const L of priceLines) {
919
+ if (!L || !isFinite(L.price)) continue;
920
+ const y = Math.round(ps.y(L.price)) + 0.5;
921
+ if (y < 0 || y > plot.h) continue;
922
+ const color = L.color || theme.textStrong;
923
+ ctx.setLineDash(DASH[L.lineStyle] || DASH.dashed);
924
+ ctx.strokeStyle = color;
925
+ ctx.lineWidth = L.lineWidth || 1;
926
+ ctx.beginPath();
927
+ ctx.moveTo(0, y);
928
+ ctx.lineTo(plot.w, y);
929
+ ctx.stroke();
930
+ ctx.setLineDash([]);
931
+ if (L.title) {
932
+ ctx.textAlign = "left";
933
+ const tw = ctx.measureText(L.title).width;
934
+ ctx.fillStyle = color;
935
+ roundRect(ctx, 6, y - 9, tw + 12, 18, 4);
936
+ ctx.fill();
937
+ ctx.fillStyle = L.titleColor || theme.tagText;
938
+ ctx.fillText(L.title, 12, y);
939
+ }
940
+ if (L.axisLabel !== false) {
941
+ const label = L.price.toFixed(dec);
942
+ ctx.textAlign = "left";
943
+ const lw = ctx.measureText(label).width;
944
+ ctx.fillStyle = color;
945
+ ctx.fillRect(plot.w + 1, y - 9, lw + 14, 18);
946
+ ctx.fillStyle = L.tagTextColor || theme.tagText;
947
+ ctx.fillText(label, plot.w + 8, y);
948
+ }
949
+ }
950
+ ctx.restore();
951
+ }
952
+ function shapePath(ctx, shape, x, y, r) {
953
+ ctx.beginPath();
954
+ switch (shape) {
955
+ case "arrowUp":
956
+ ctx.moveTo(x, y - r);
957
+ ctx.lineTo(x + r, y);
958
+ ctx.lineTo(x + r * 0.45, y);
959
+ ctx.lineTo(x + r * 0.45, y + r);
960
+ ctx.lineTo(x - r * 0.45, y + r);
961
+ ctx.lineTo(x - r * 0.45, y);
962
+ ctx.lineTo(x - r, y);
963
+ ctx.closePath();
964
+ break;
965
+ case "arrowDown":
966
+ ctx.moveTo(x, y + r);
967
+ ctx.lineTo(x + r, y);
968
+ ctx.lineTo(x + r * 0.45, y);
969
+ ctx.lineTo(x + r * 0.45, y - r);
970
+ ctx.lineTo(x - r * 0.45, y - r);
971
+ ctx.lineTo(x - r * 0.45, y);
972
+ ctx.lineTo(x - r, y);
973
+ ctx.closePath();
974
+ break;
975
+ case "triangleUp":
976
+ ctx.moveTo(x, y - r);
977
+ ctx.lineTo(x + r, y + r);
978
+ ctx.lineTo(x - r, y + r);
979
+ ctx.closePath();
980
+ break;
981
+ case "triangleDown":
982
+ ctx.moveTo(x, y + r);
983
+ ctx.lineTo(x + r, y - r);
984
+ ctx.lineTo(x - r, y - r);
985
+ ctx.closePath();
986
+ break;
987
+ case "square":
988
+ ctx.rect(x - r, y - r, r * 2, r * 2);
989
+ break;
990
+ case "diamond":
991
+ ctx.moveTo(x, y - r);
992
+ ctx.lineTo(x + r, y);
993
+ ctx.lineTo(x, y + r);
994
+ ctx.lineTo(x - r, y);
995
+ ctx.closePath();
996
+ break;
997
+ default:
998
+ ctx.arc(x, y, r, 0, Math.PI * 2);
999
+ }
1000
+ }
1001
+ function drawFlag(ctx, x, y, r, color) {
1002
+ ctx.fillStyle = color;
1003
+ ctx.fillRect(x - r * 0.8, y - r, Math.max(1, r * 0.3), r * 2);
1004
+ ctx.beginPath();
1005
+ ctx.moveTo(x - r * 0.5, y - r);
1006
+ ctx.lineTo(x + r, y - r * 0.55);
1007
+ ctx.lineTo(x - r * 0.5, y - r * 0.1);
1008
+ ctx.closePath();
1009
+ ctx.fill();
1010
+ }
1011
+ function drawMarkers(ctx, s, placed, hoverId) {
1012
+ const hits = [];
1013
+ if (!placed || !placed.length) return hits;
1014
+ const { theme } = s;
1015
+ ctx.save();
1016
+ ctx.font = theme.font;
1017
+ ctx.textAlign = "center";
1018
+ ctx.textBaseline = "middle";
1019
+ for (const p of placed) {
1020
+ const { m, x, y, r, dir } = p;
1021
+ const color = m.color || (DOWN_SHAPES.has(m.shape) ? theme.down : theme.up);
1022
+ const hovered = hoverId != null && m.id === hoverId;
1023
+ if (m.shape === "label") {
1024
+ const text = m.text || "•";
1025
+ const w = ctx.measureText(text).width + 14;
1026
+ const h = 18 * m.size;
1027
+ roundRect(ctx, x - w / 2, y - h / 2, w, h, 4);
1028
+ ctx.fillStyle = color;
1029
+ ctx.fill();
1030
+ if (hovered) {
1031
+ ctx.strokeStyle = theme.textStrong;
1032
+ ctx.lineWidth = 1.5;
1033
+ ctx.stroke();
1034
+ }
1035
+ ctx.fillStyle = m.textColor || theme.tagText;
1036
+ ctx.fillText(text, x, y + 0.5);
1037
+ hits.push({ id: m.id, marker: m, x, y, r: Math.max(w, h) / 2 });
1038
+ continue;
1039
+ }
1040
+ if (hovered) {
1041
+ ctx.beginPath();
1042
+ ctx.arc(x, y, r + 4, 0, Math.PI * 2);
1043
+ ctx.fillStyle = "rgba(255,255,255,0.14)";
1044
+ ctx.fill();
1045
+ }
1046
+ if (m.shape === "flag") {
1047
+ drawFlag(ctx, x, y, r, color);
1048
+ } else {
1049
+ shapePath(ctx, m.shape, x, y, r);
1050
+ ctx.fillStyle = color;
1051
+ ctx.fill();
1052
+ }
1053
+ if (m.text) {
1054
+ ctx.fillStyle = m.textColor || theme.text;
1055
+ ctx.fillText(m.text, x, dir >= 0 ? y + r + 9 : y - r - 9);
1056
+ }
1057
+ hits.push({ id: m.id, marker: m, x, y, r: r + 3 });
1058
+ }
1059
+ ctx.restore();
1060
+ return hits;
1061
+ }
736
1062
  class Chart {
737
1063
  constructor(container, options = {}) {
738
1064
  if (!container) throw new Error("Chart: container element is required");
@@ -749,7 +1075,18 @@ class Chart {
749
1075
  this._unsub = null;
750
1076
  this._loadingHistory = false;
751
1077
  this._exhausted = false;
752
- this._listeners = { crosshair: /* @__PURE__ */ new Set(), visibleRange: /* @__PURE__ */ new Set() };
1078
+ this._listeners = {
1079
+ crosshair: /* @__PURE__ */ new Set(),
1080
+ visibleRange: /* @__PURE__ */ new Set(),
1081
+ markerClick: /* @__PURE__ */ new Set(),
1082
+ markerHover: /* @__PURE__ */ new Set()
1083
+ };
1084
+ this._markers = normalizeMarkers(options.markers);
1085
+ this.priceLines = Array.isArray(options.priceLines) ? options.priceLines.slice() : [];
1086
+ this.zones = Array.isArray(options.zones) ? options.zones.slice() : [];
1087
+ this._markerHits = [];
1088
+ this._hoverMarkerId = null;
1089
+ this._resolveKey = "";
753
1090
  this.layers = new Layers(container, ["base", "main", "overlay"]);
754
1091
  this.ts = new TimeScale(options.timeScale);
755
1092
  this.ps = new PriceScale(options.priceScale);
@@ -1000,6 +1337,14 @@ class Chart {
1000
1337
  } else return;
1001
1338
  e.preventDefault();
1002
1339
  };
1340
+ this._onClick = (e) => {
1341
+ if (moved) return;
1342
+ if (!this._listeners.markerClick.size) return;
1343
+ const p = localPos(e);
1344
+ const hit = this.markerAt(p.x, p.y);
1345
+ if (hit) for (const fn of this._listeners.markerClick) fn(hit);
1346
+ };
1347
+ el.addEventListener("click", this._onClick);
1003
1348
  el.addEventListener("pointerdown", this._onDown);
1004
1349
  el.addEventListener("pointermove", this._onMove);
1005
1350
  el.addEventListener("pointerup", this._onUp);
@@ -1011,14 +1356,39 @@ class Chart {
1011
1356
  if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "0");
1012
1357
  }
1013
1358
  _emitCrosshair(p) {
1014
- if (!this._listeners.crosshair.size) return;
1015
- let payload = null;
1016
- if (p && this.bars.length && p.x <= this.plot.w && p.y <= this.plot.h) {
1017
- const i = Math.round(this.ts.index(p.x));
1018
- const bar = this.bars[i];
1019
- if (bar) payload = { index: i, bar, price: this.ps.price(p.y) };
1359
+ if (this._listeners.crosshair.size) {
1360
+ let payload = null;
1361
+ if (p && this.bars.length && p.x <= this.plot.w && p.y <= this.plot.h) {
1362
+ const i = Math.round(this.ts.index(p.x));
1363
+ const bar = this.bars[i];
1364
+ if (bar) payload = { index: i, bar, price: this.ps.price(p.y) };
1365
+ }
1366
+ for (const fn of this._listeners.crosshair) fn(payload);
1020
1367
  }
1021
- for (const fn of this._listeners.crosshair) fn(payload);
1368
+ this._updateHover(p);
1369
+ }
1370
+ /**
1371
+ * Topmost marker whose hit circle contains the point, else null.
1372
+ * Hit circles come from the last render, so this costs nothing but a loop.
1373
+ */
1374
+ markerAt(x, y) {
1375
+ const hits = this._markerHits;
1376
+ for (let i = hits.length - 1; i >= 0; i--) {
1377
+ const h = hits[i];
1378
+ const dx = x - h.x;
1379
+ const dy = y - h.y;
1380
+ if (dx * dx + dy * dy <= h.r * h.r) return h.marker;
1381
+ }
1382
+ return null;
1383
+ }
1384
+ _updateHover(p) {
1385
+ const hit = p ? this.markerAt(p.x, p.y) : null;
1386
+ const id = hit ? hit.id : null;
1387
+ if (id === this._hoverMarkerId) return;
1388
+ this._hoverMarkerId = id;
1389
+ this.container.style.cursor = hit ? "pointer" : "crosshair";
1390
+ this.loop.invalidate("main");
1391
+ for (const fn of this._listeners.markerHover) fn(hit);
1022
1392
  }
1023
1393
  subscribe(event, fn) {
1024
1394
  const set = this._listeners[event];
@@ -1026,6 +1396,37 @@ class Chart {
1026
1396
  set.add(fn);
1027
1397
  return () => set.delete(fn);
1028
1398
  }
1399
+ // ----------------------------------------------------------- annotations --
1400
+ /** Replace every marker. Each `time` is resolved to its nearest bar. */
1401
+ setMarkers(markers) {
1402
+ this._markers = normalizeMarkers(markers);
1403
+ this._resolveKey = "";
1404
+ this.loop.invalidate("main");
1405
+ }
1406
+ /** The current markers, normalised, each with its resolved bar index. */
1407
+ getMarkers() {
1408
+ return this._markers.slice();
1409
+ }
1410
+ addMarker(marker) {
1411
+ this.setMarkers(this._markers.concat([marker]));
1412
+ }
1413
+ removeMarker(id) {
1414
+ const key = String(id);
1415
+ this.setMarkers(this._markers.filter((m) => m.id !== key));
1416
+ }
1417
+ clearMarkers() {
1418
+ this.setMarkers([]);
1419
+ }
1420
+ /** Horizontal lines — entries, stops, targets, alerts. */
1421
+ setPriceLines(lines) {
1422
+ this.priceLines = Array.isArray(lines) ? lines.slice() : [];
1423
+ this.loop.invalidate("main");
1424
+ }
1425
+ /** Shaded regions: a price band ({from,to}) or a time band ({fromTime,toTime}). */
1426
+ setZones(zones) {
1427
+ this.zones = Array.isArray(zones) ? zones.slice() : [];
1428
+ this.loop.invalidate("all");
1429
+ }
1029
1430
  // ----------------------------------------------------------------- frame --
1030
1431
  _frame(dirty, dt) {
1031
1432
  let animating = false;
@@ -1055,11 +1456,28 @@ class Chart {
1055
1456
  live: liveVisible,
1056
1457
  volumeRatio: this.options.volumeRatio,
1057
1458
  cursor: this.cursor,
1058
- magnet: this.options.magnet
1459
+ magnet: this.options.magnet,
1460
+ priceLines: this.priceLines,
1461
+ zones: this.zones
1059
1462
  };
1463
+ if (this._markers.length) {
1464
+ const key = this.bars.length + ":" + (this.bars.length ? this.bars[0].time : 0);
1465
+ if (key !== this._resolveKey) {
1466
+ resolveMarkers(this._markers, this.bars);
1467
+ this._resolveKey = key;
1468
+ }
1469
+ }
1060
1470
  if (redrawAll) {
1061
1471
  drawGrid(this.layers.ctx.base, state);
1472
+ drawZones(this.layers.ctx.base, state);
1062
1473
  drawCandles(this.layers.ctx.main, state);
1474
+ drawPriceLines(this.layers.ctx.main, state);
1475
+ this._markerHits = drawMarkers(
1476
+ this.layers.ctx.main,
1477
+ state,
1478
+ layoutMarkers(this._markers, state),
1479
+ this._hoverMarkerId
1480
+ );
1063
1481
  }
1064
1482
  if (redrawAll || dirty.has("overlay")) {
1065
1483
  drawCrosshair(this.layers.ctx.overlay, state);
@@ -1105,11 +1523,15 @@ class Chart {
1105
1523
  el.removeEventListener("wheel", this._onWheel);
1106
1524
  el.removeEventListener("dblclick", this._onDbl);
1107
1525
  el.removeEventListener("keydown", this._onKey);
1526
+ el.removeEventListener("click", this._onClick);
1108
1527
  this.detachFeed();
1109
1528
  this.loop.stop();
1110
1529
  this.layers.destroy();
1111
- this._listeners.crosshair.clear();
1112
- this._listeners.visibleRange.clear();
1530
+ for (const set of Object.values(this._listeners)) set.clear();
1531
+ this._markers = [];
1532
+ this._markerHits = [];
1533
+ this.priceLines = [];
1534
+ this.zones = [];
1113
1535
  this.bars = [];
1114
1536
  }
1115
1537
  }
@@ -1288,7 +1710,7 @@ class RandomFeed extends DataFeed {
1288
1710
  function createChart(container, options) {
1289
1711
  return new Chart(container, options);
1290
1712
  }
1291
- const version = "0.1.0";
1713
+ const version = "0.2.0";
1292
1714
  export {
1293
1715
  Chart,
1294
1716
  DataFeed,