emberwick 0.1.1 → 0.3.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/README.md +150 -14
- package/index.d.ts +150 -2
- package/index.js +484 -12
- package/index.js.map +1 -1
- package/package.json +1 -2
- package/umd/emberwick.umd.js +1 -1
- package/umd/emberwick.umd.js.map +1 -1
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 = {
|
|
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,21 +1356,126 @@ class Chart {
|
|
|
1011
1356
|
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "0");
|
|
1012
1357
|
}
|
|
1013
1358
|
_emitCrosshair(p) {
|
|
1014
|
-
if (
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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];
|
|
1025
1395
|
if (!set) throw new Error(`Chart: unknown event "${event}"`);
|
|
1026
1396
|
set.add(fn);
|
|
1397
|
+
if (event === "visibleRange") {
|
|
1398
|
+
const payload = this.visibleRange();
|
|
1399
|
+
this._rangeKey = this._rangeIdentity(payload);
|
|
1400
|
+
fn(payload);
|
|
1401
|
+
}
|
|
1027
1402
|
return () => set.delete(fn);
|
|
1028
1403
|
}
|
|
1404
|
+
// ------------------------------------------------------------------ range --
|
|
1405
|
+
/**
|
|
1406
|
+
* The window currently on screen. Cheap enough to poll, though
|
|
1407
|
+
* subscribe('visibleRange', fn) is the better way to track it.
|
|
1408
|
+
*/
|
|
1409
|
+
visibleRange() {
|
|
1410
|
+
const { from, to } = this.ts.visibleRange();
|
|
1411
|
+
return this._rangePayload(from, to);
|
|
1412
|
+
}
|
|
1413
|
+
_rangePayload(from, to) {
|
|
1414
|
+
const n = this.bars.length;
|
|
1415
|
+
return {
|
|
1416
|
+
from,
|
|
1417
|
+
to,
|
|
1418
|
+
fromTime: n ? this.bars[from].time : null,
|
|
1419
|
+
toTime: n ? this.bars[to].time : null,
|
|
1420
|
+
barCount: n,
|
|
1421
|
+
spacing: this.ts.spacing,
|
|
1422
|
+
settled: this.ts.settled
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
_rangeIdentity(p) {
|
|
1426
|
+
return `${p.from}:${p.to}:${p.fromTime}:${p.toTime}:${p.settled ? 1 : 0}`;
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Fires only when the window actually changed, so a consumer can hang a
|
|
1430
|
+
* fetch off it without debouncing. Two deliberate choices:
|
|
1431
|
+
*
|
|
1432
|
+
* - `spacing` is NOT part of the identity. It is a float that moves on every
|
|
1433
|
+
* frame of an eased zoom, so keying on it would make this a 60/sec
|
|
1434
|
+
* firehose. It is still reported, for level-of-detail decisions.
|
|
1435
|
+
* - `settled` IS part of the identity, so the final event of a gesture
|
|
1436
|
+
* always arrives with settled:true. Without it, code that defers expensive
|
|
1437
|
+
* work until the view stops moving would wait forever.
|
|
1438
|
+
*/
|
|
1439
|
+
_emitVisibleRange(from, to) {
|
|
1440
|
+
const set = this._listeners.visibleRange;
|
|
1441
|
+
if (!set.size) return;
|
|
1442
|
+
const payload = this._rangePayload(from, to);
|
|
1443
|
+
const key = this._rangeIdentity(payload);
|
|
1444
|
+
if (key === this._rangeKey) return;
|
|
1445
|
+
this._rangeKey = key;
|
|
1446
|
+
for (const fn of set) fn(payload);
|
|
1447
|
+
}
|
|
1448
|
+
// ----------------------------------------------------------- annotations --
|
|
1449
|
+
/** Replace every marker. Each `time` is resolved to its nearest bar. */
|
|
1450
|
+
setMarkers(markers) {
|
|
1451
|
+
this._markers = normalizeMarkers(markers);
|
|
1452
|
+
this._resolveKey = "";
|
|
1453
|
+
this.loop.invalidate("main");
|
|
1454
|
+
}
|
|
1455
|
+
/** The current markers, normalised, each with its resolved bar index. */
|
|
1456
|
+
getMarkers() {
|
|
1457
|
+
return this._markers.slice();
|
|
1458
|
+
}
|
|
1459
|
+
addMarker(marker) {
|
|
1460
|
+
this.setMarkers(this._markers.concat([marker]));
|
|
1461
|
+
}
|
|
1462
|
+
removeMarker(id) {
|
|
1463
|
+
const key = String(id);
|
|
1464
|
+
this.setMarkers(this._markers.filter((m) => m.id !== key));
|
|
1465
|
+
}
|
|
1466
|
+
clearMarkers() {
|
|
1467
|
+
this.setMarkers([]);
|
|
1468
|
+
}
|
|
1469
|
+
/** Horizontal lines — entries, stops, targets, alerts. */
|
|
1470
|
+
setPriceLines(lines) {
|
|
1471
|
+
this.priceLines = Array.isArray(lines) ? lines.slice() : [];
|
|
1472
|
+
this.loop.invalidate("main");
|
|
1473
|
+
}
|
|
1474
|
+
/** Shaded regions: a price band ({from,to}) or a time band ({fromTime,toTime}). */
|
|
1475
|
+
setZones(zones) {
|
|
1476
|
+
this.zones = Array.isArray(zones) ? zones.slice() : [];
|
|
1477
|
+
this.loop.invalidate("all");
|
|
1478
|
+
}
|
|
1029
1479
|
// ----------------------------------------------------------------- frame --
|
|
1030
1480
|
_frame(dirty, dt) {
|
|
1031
1481
|
let animating = false;
|
|
@@ -1055,15 +1505,33 @@ class Chart {
|
|
|
1055
1505
|
live: liveVisible,
|
|
1056
1506
|
volumeRatio: this.options.volumeRatio,
|
|
1057
1507
|
cursor: this.cursor,
|
|
1058
|
-
magnet: this.options.magnet
|
|
1508
|
+
magnet: this.options.magnet,
|
|
1509
|
+
priceLines: this.priceLines,
|
|
1510
|
+
zones: this.zones
|
|
1059
1511
|
};
|
|
1512
|
+
if (this._markers.length) {
|
|
1513
|
+
const key = this.bars.length + ":" + (this.bars.length ? this.bars[0].time : 0);
|
|
1514
|
+
if (key !== this._resolveKey) {
|
|
1515
|
+
resolveMarkers(this._markers, this.bars);
|
|
1516
|
+
this._resolveKey = key;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1060
1519
|
if (redrawAll) {
|
|
1061
1520
|
drawGrid(this.layers.ctx.base, state);
|
|
1521
|
+
drawZones(this.layers.ctx.base, state);
|
|
1062
1522
|
drawCandles(this.layers.ctx.main, state);
|
|
1523
|
+
drawPriceLines(this.layers.ctx.main, state);
|
|
1524
|
+
this._markerHits = drawMarkers(
|
|
1525
|
+
this.layers.ctx.main,
|
|
1526
|
+
state,
|
|
1527
|
+
layoutMarkers(this._markers, state),
|
|
1528
|
+
this._hoverMarkerId
|
|
1529
|
+
);
|
|
1063
1530
|
}
|
|
1064
1531
|
if (redrawAll || dirty.has("overlay")) {
|
|
1065
1532
|
drawCrosshair(this.layers.ctx.overlay, state);
|
|
1066
1533
|
}
|
|
1534
|
+
this._emitVisibleRange(from, to);
|
|
1067
1535
|
return animating;
|
|
1068
1536
|
}
|
|
1069
1537
|
// ------------------------------------------------------------------- api --
|
|
@@ -1105,11 +1573,15 @@ class Chart {
|
|
|
1105
1573
|
el.removeEventListener("wheel", this._onWheel);
|
|
1106
1574
|
el.removeEventListener("dblclick", this._onDbl);
|
|
1107
1575
|
el.removeEventListener("keydown", this._onKey);
|
|
1576
|
+
el.removeEventListener("click", this._onClick);
|
|
1108
1577
|
this.detachFeed();
|
|
1109
1578
|
this.loop.stop();
|
|
1110
1579
|
this.layers.destroy();
|
|
1111
|
-
this._listeners.
|
|
1112
|
-
this.
|
|
1580
|
+
for (const set of Object.values(this._listeners)) set.clear();
|
|
1581
|
+
this._markers = [];
|
|
1582
|
+
this._markerHits = [];
|
|
1583
|
+
this.priceLines = [];
|
|
1584
|
+
this.zones = [];
|
|
1113
1585
|
this.bars = [];
|
|
1114
1586
|
}
|
|
1115
1587
|
}
|
|
@@ -1288,7 +1760,7 @@ class RandomFeed extends DataFeed {
|
|
|
1288
1760
|
function createChart(container, options) {
|
|
1289
1761
|
return new Chart(container, options);
|
|
1290
1762
|
}
|
|
1291
|
-
const version = "0.
|
|
1763
|
+
const version = "0.2.0";
|
|
1292
1764
|
export {
|
|
1293
1765
|
Chart,
|
|
1294
1766
|
DataFeed,
|