hyperprop-charting-library 0.1.142 → 0.1.144

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/dist/index.js CHANGED
@@ -335,26 +335,45 @@ var getSeriesFingerprint = (data) => {
335
335
  var withCachedSeries = (key, data, compute) => {
336
336
  const fingerprint = getSeriesFingerprint(data);
337
337
  const existing = builtInSeriesCache.get(key);
338
- if (existing && existing.fingerprint === fingerprint) {
339
- return existing.values;
338
+ const shape = getSeriesShape(data);
339
+ if (existing) {
340
+ const unchanged = existing.fingerprint === fingerprint;
341
+ const liveTickWithinThrottle = !unchanged && indicatorLiveThrottleMs > 0 && existing.shape === shape && Date.now() - existing.computedAt < indicatorLiveThrottleMs;
342
+ if (unchanged || liveTickWithinThrottle) {
343
+ return existing.values;
344
+ }
340
345
  }
341
346
  const values = compute();
342
- builtInSeriesCache.set(key, { fingerprint, values });
347
+ builtInSeriesCache.set(key, { fingerprint, values, shape, computedAt: Date.now() });
343
348
  return values;
344
349
  };
345
350
  var builtInComputationCache = /* @__PURE__ */ new Map();
351
+ var indicatorLiveThrottleMs = 80;
352
+ var setIndicatorLiveThrottleMs = (ms) => {
353
+ indicatorLiveThrottleMs = Math.max(0, ms);
354
+ };
355
+ var getSeriesShape = (data) => {
356
+ const length = data.length;
357
+ const last = length > 0 ? data[length - 1] : void 0;
358
+ return last ? `${length}|${last.time.getTime()}` : "empty";
359
+ };
346
360
  var COMPUTATION_CACHE_MAX_ENTRIES = 128;
347
361
  var withCachedComputation = (key, data, compute) => {
348
362
  const fingerprint = getSeriesFingerprint(data);
349
363
  const existing = builtInComputationCache.get(key);
350
- if (existing && existing.fingerprint === fingerprint) {
351
- builtInComputationCache.delete(key);
352
- builtInComputationCache.set(key, existing);
353
- return existing.value;
364
+ const shape = getSeriesShape(data);
365
+ if (existing) {
366
+ const unchanged = existing.fingerprint === fingerprint;
367
+ const liveTickWithinThrottle = !unchanged && indicatorLiveThrottleMs > 0 && existing.shape === shape && Date.now() - existing.computedAt < indicatorLiveThrottleMs;
368
+ if (unchanged || liveTickWithinThrottle) {
369
+ builtInComputationCache.delete(key);
370
+ builtInComputationCache.set(key, existing);
371
+ return existing.value;
372
+ }
354
373
  }
355
374
  const value = compute();
356
375
  builtInComputationCache.delete(key);
357
- builtInComputationCache.set(key, { fingerprint, value });
376
+ builtInComputationCache.set(key, { fingerprint, value, shape, computedAt: Date.now() });
358
377
  while (builtInComputationCache.size > COMPUTATION_CACHE_MAX_ENTRIES) {
359
378
  const oldest = builtInComputationCache.keys().next().value;
360
379
  if (oldest === void 0) break;
@@ -2724,6 +2743,237 @@ var CHART_THEMES = {
2724
2743
  var CHART_THEME_NAMES = Object.keys(CHART_THEMES);
2725
2744
  var resolveTheme = (theme) => typeof theme === "string" ? CHART_THEMES[theme] ?? CHART_THEMES.midnight : theme;
2726
2745
 
2746
+ // src/time.ts
2747
+ var DAY_MS = 864e5;
2748
+ var HOUR_MS = 36e5;
2749
+ var OFFSET_FORMATTER_CACHE = /* @__PURE__ */ new Map();
2750
+ var LABEL_FORMATTER_CACHE = /* @__PURE__ */ new Map();
2751
+ var getOffsetFormatter = (timeZone) => {
2752
+ let formatter = OFFSET_FORMATTER_CACHE.get(timeZone);
2753
+ if (!formatter) {
2754
+ formatter = new Intl.DateTimeFormat("en-US", {
2755
+ timeZone,
2756
+ hourCycle: "h23",
2757
+ year: "numeric",
2758
+ month: "2-digit",
2759
+ day: "2-digit",
2760
+ hour: "2-digit",
2761
+ minute: "2-digit",
2762
+ second: "2-digit"
2763
+ });
2764
+ OFFSET_FORMATTER_CACHE.set(timeZone, formatter);
2765
+ }
2766
+ return formatter;
2767
+ };
2768
+ var getLabelFormatter = (timeZone, options, key) => {
2769
+ const cacheKey = `${timeZone ?? "local"}|${key}`;
2770
+ let formatter = LABEL_FORMATTER_CACHE.get(cacheKey);
2771
+ if (!formatter) {
2772
+ formatter = new Intl.DateTimeFormat(void 0, timeZone ? { ...options, timeZone } : options);
2773
+ LABEL_FORMATTER_CACHE.set(cacheKey, formatter);
2774
+ }
2775
+ return formatter;
2776
+ };
2777
+ var isValidTimeZone = (timeZone) => {
2778
+ try {
2779
+ new Intl.DateTimeFormat("en-US", { timeZone });
2780
+ return true;
2781
+ } catch {
2782
+ return false;
2783
+ }
2784
+ };
2785
+ var floorDiv = (value, divisor) => Math.floor(value / divisor);
2786
+ var positiveMod = (value, divisor) => (value % divisor + divisor) % divisor;
2787
+ var computeOffsetMs = (timeZone, ms) => {
2788
+ const parts = getOffsetFormatter(timeZone).formatToParts(new Date(ms));
2789
+ let year = 0;
2790
+ let month = 1;
2791
+ let day = 1;
2792
+ let hour = 0;
2793
+ let minute = 0;
2794
+ let second = 0;
2795
+ for (const part of parts) {
2796
+ const value = Number(part.value);
2797
+ if (part.type === "year") year = value;
2798
+ else if (part.type === "month") month = value;
2799
+ else if (part.type === "day") day = value;
2800
+ else if (part.type === "hour") hour = value === 24 ? 0 : value;
2801
+ else if (part.type === "minute") minute = value;
2802
+ else if (part.type === "second") second = value;
2803
+ }
2804
+ const asUtc = Date.UTC(year, month - 1, day, hour, minute, second);
2805
+ return asUtc - Math.floor(ms / 1e3) * 1e3;
2806
+ };
2807
+ var createZoneClock = (timeZone) => {
2808
+ const normalized = !timeZone || timeZone === "local" ? null : timeZone.toLowerCase() === "utc" ? "UTC" : isValidTimeZone(timeZone) ? timeZone : null;
2809
+ if (normalized === null) {
2810
+ const scratch = /* @__PURE__ */ new Date();
2811
+ const partsOf2 = (ms) => {
2812
+ scratch.setTime(ms);
2813
+ return {
2814
+ year: scratch.getFullYear(),
2815
+ month: scratch.getMonth() + 1,
2816
+ day: scratch.getDate(),
2817
+ hour: scratch.getHours(),
2818
+ minute: scratch.getMinutes()
2819
+ };
2820
+ };
2821
+ return {
2822
+ timeZone: null,
2823
+ offsetMs: (ms) => {
2824
+ scratch.setTime(ms);
2825
+ return -scratch.getTimezoneOffset() * 6e4;
2826
+ },
2827
+ dayKey: (ms) => {
2828
+ scratch.setTime(ms);
2829
+ return floorDiv(Date.UTC(scratch.getFullYear(), scratch.getMonth(), scratch.getDate()), DAY_MS);
2830
+ },
2831
+ minutesOfDay: (ms) => {
2832
+ scratch.setTime(ms);
2833
+ return scratch.getHours() * 60 + scratch.getMinutes();
2834
+ },
2835
+ weekday: (ms) => {
2836
+ scratch.setTime(ms);
2837
+ return scratch.getDay();
2838
+ },
2839
+ parts: partsOf2,
2840
+ formatTime: (ms, hour12) => formatClockTime(partsOf2(ms), hour12),
2841
+ formatDayMonth: (ms) => getLabelFormatter(null, { month: "short", day: "numeric" }, "md").format(ms),
2842
+ formatMonth: (ms) => getLabelFormatter(null, { month: "short" }, "m").format(ms),
2843
+ formatYear: (ms) => String(partsOf2(ms).year)
2844
+ };
2845
+ }
2846
+ const zone = normalized;
2847
+ const offsetCache = /* @__PURE__ */ new Map();
2848
+ const offsetMs = (ms) => {
2849
+ const bucket = floorDiv(ms, HOUR_MS);
2850
+ const cached = offsetCache.get(bucket);
2851
+ if (cached !== void 0) return cached;
2852
+ const offset = computeOffsetMs(zone, ms);
2853
+ if (offsetCache.size > 2e4) offsetCache.clear();
2854
+ offsetCache.set(bucket, offset);
2855
+ return offset;
2856
+ };
2857
+ const localMs = (ms) => ms + offsetMs(ms);
2858
+ const partsOf = (ms) => {
2859
+ const local = new Date(localMs(ms));
2860
+ return {
2861
+ year: local.getUTCFullYear(),
2862
+ month: local.getUTCMonth() + 1,
2863
+ day: local.getUTCDate(),
2864
+ hour: local.getUTCHours(),
2865
+ minute: local.getUTCMinutes()
2866
+ };
2867
+ };
2868
+ return {
2869
+ timeZone: zone,
2870
+ offsetMs,
2871
+ dayKey: (ms) => floorDiv(localMs(ms), DAY_MS),
2872
+ minutesOfDay: (ms) => floorDiv(positiveMod(localMs(ms), DAY_MS), 6e4),
2873
+ // 1970-01-01 was a Thursday (index 4).
2874
+ weekday: (ms) => positiveMod(floorDiv(localMs(ms), DAY_MS) + 4, 7),
2875
+ parts: partsOf,
2876
+ formatTime: (ms, hour12) => formatClockTime(partsOf(ms), hour12),
2877
+ formatDayMonth: (ms) => getLabelFormatter(zone, { month: "short", day: "numeric" }, "md").format(ms),
2878
+ formatMonth: (ms) => getLabelFormatter(zone, { month: "short" }, "m").format(ms),
2879
+ formatYear: (ms) => String(partsOf(ms).year)
2880
+ };
2881
+ };
2882
+ var pad2 = (value) => value < 10 ? `0${value}` : String(value);
2883
+ var formatClockTime = (parts, hour12) => {
2884
+ if (!hour12) return `${pad2(parts.hour)}:${pad2(parts.minute)}`;
2885
+ const suffix = parts.hour < 12 ? "AM" : "PM";
2886
+ const hour = parts.hour % 12 === 0 ? 12 : parts.hour % 12;
2887
+ return `${hour}:${pad2(parts.minute)} ${suffix}`;
2888
+ };
2889
+ var parseHhMm = (value) => {
2890
+ const match = /^(\d{1,2}):?(\d{2})$/.exec(value.trim());
2891
+ if (!match) return null;
2892
+ const hour = Number(match[1]);
2893
+ const minute = Number(match[2]);
2894
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
2895
+ if (hour < 0 || hour > 24 || minute < 0 || minute > 59) return null;
2896
+ return hour * 60 + minute;
2897
+ };
2898
+ var SESSION_PRESETS = {
2899
+ "cme-futures": {
2900
+ timezone: "America/New_York",
2901
+ // Opening days: the session opens Sunday–Thursday evening and runs into
2902
+ // the following morning, so Friday evening (weekend) is correctly closed.
2903
+ days: [0, 1, 2, 3, 4],
2904
+ segments: [{ start: "18:00", end: "17:00" }]
2905
+ },
2906
+ "cme-rth": {
2907
+ timezone: "America/New_York",
2908
+ days: [1, 2, 3, 4, 5],
2909
+ segments: [{ start: "09:30", end: "16:00" }]
2910
+ },
2911
+ "us-equities": {
2912
+ timezone: "America/New_York",
2913
+ days: [1, 2, 3, 4, 5],
2914
+ segments: [{ start: "09:30", end: "16:00" }]
2915
+ },
2916
+ "us-equities-extended": {
2917
+ timezone: "America/New_York",
2918
+ days: [1, 2, 3, 4, 5],
2919
+ segments: [{ start: "04:00", end: "20:00" }]
2920
+ },
2921
+ "24x7": {
2922
+ days: [0, 1, 2, 3, 4, 5, 6],
2923
+ segments: [{ start: "00:00", end: "24:00" }]
2924
+ }
2925
+ };
2926
+ var resolveSessionSpec = (spec) => {
2927
+ if (!spec) return null;
2928
+ if (typeof spec === "string") return SESSION_PRESETS[spec] ?? null;
2929
+ return spec;
2930
+ };
2931
+ var compileSession = (spec) => {
2932
+ const resolved = resolveSessionSpec(spec);
2933
+ if (!resolved) return null;
2934
+ const rawSegments = resolved.segments?.length ? resolved.segments : [{ start: "00:00", end: "24:00" }];
2935
+ const segments = [];
2936
+ for (const segment of rawSegments) {
2937
+ const startMinutes = parseHhMm(segment.start);
2938
+ const endMinutesRaw = parseHhMm(segment.end);
2939
+ if (startMinutes === null || endMinutesRaw === null) continue;
2940
+ const endMinutes = endMinutesRaw === 0 ? 1440 : endMinutesRaw;
2941
+ segments.push({ startMinutes, endMinutes, wraps: endMinutes <= startMinutes });
2942
+ }
2943
+ if (segments.length === 0) return null;
2944
+ return {
2945
+ clock: createZoneClock(resolved.timezone ?? null),
2946
+ days: new Set(resolved.days ?? [1, 2, 3, 4, 5]),
2947
+ segments,
2948
+ overnight: segments.some((segment) => segment.wraps)
2949
+ };
2950
+ };
2951
+ var sessionInfoAt = (session, ms) => {
2952
+ const { clock } = session;
2953
+ const dayKey = clock.dayKey(ms);
2954
+ const minutes = clock.minutesOfDay(ms);
2955
+ const weekday = clock.weekday(ms);
2956
+ let sessionDay = dayKey;
2957
+ let inSession = false;
2958
+ for (const segment of session.segments) {
2959
+ if (segment.wraps) {
2960
+ if (minutes >= segment.startMinutes) {
2961
+ if (session.days.has(weekday)) {
2962
+ inSession = true;
2963
+ sessionDay = dayKey + 1;
2964
+ }
2965
+ } else if (minutes < segment.endMinutes) {
2966
+ const openedWeekday = positiveMod(weekday - 1, 7);
2967
+ if (session.days.has(openedWeekday)) inSession = true;
2968
+ sessionDay = dayKey;
2969
+ }
2970
+ } else if (minutes >= segment.startMinutes && minutes < segment.endMinutes && session.days.has(weekday)) {
2971
+ inSession = true;
2972
+ }
2973
+ }
2974
+ return { inSession, sessionDay };
2975
+ };
2976
+
2727
2977
  // src/options.ts
2728
2978
  var DEFAULT_GRID_OPTIONS = {
2729
2979
  color: "#2b2f38",
@@ -2924,10 +3174,36 @@ var DEFAULT_OPTIONS = {
2924
3174
  accessibility: { label: "Price chart", description: "", focusable: true },
2925
3175
  downsampling: { enabled: true, thresholdPx: 1.5 },
2926
3176
  touch: { hitToleranceScale: 2.2, longPressTooltip: true, longPressMs: 400 },
3177
+ dataLine: {
3178
+ visible: false,
3179
+ symbol: "",
3180
+ interval: "",
3181
+ exchange: "",
3182
+ showOhlc: true,
3183
+ showChange: true,
3184
+ showVolume: false,
3185
+ details: [],
3186
+ statusColor: "",
3187
+ fontSize: 0,
3188
+ symbolColor: "",
3189
+ textColor: ""
3190
+ },
2927
3191
  datafeedOptions: { prefetchThresholdBars: 150, cooldownMs: 1200, chunkBars: 1500 },
3192
+ animation: { viewportTransitions: true, durationMs: 260 },
3193
+ indicatorUpdate: { liveThrottleMs: 80 },
3194
+ timezone: "local",
3195
+ timeFormat: "24h",
3196
+ session: {
3197
+ spec: null,
3198
+ separators: true,
3199
+ highlightOutOfSession: false,
3200
+ outOfSessionColor: "rgba(120,132,158,0.07)"
3201
+ },
3202
+ history: { enabled: true, limit: 100 },
2928
3203
  crosshair: DEFAULT_CROSSHAIR_OPTIONS,
2929
3204
  grid: DEFAULT_GRID_OPTIONS,
2930
3205
  watermark: DEFAULT_WATERMARK_OPTIONS,
3206
+ alerts: [],
2931
3207
  priceLines: [],
2932
3208
  orderLines: [],
2933
3209
  tickerLine: {
@@ -2983,6 +3259,26 @@ var mergeChartOptions = (baseOptions, options = {}) => ({
2983
3259
  ...baseOptions.touch,
2984
3260
  ...options.touch ?? {}
2985
3261
  },
3262
+ dataLine: {
3263
+ ...baseOptions.dataLine,
3264
+ ...options.dataLine ?? {}
3265
+ },
3266
+ animation: {
3267
+ ...baseOptions.animation,
3268
+ ...options.animation ?? {}
3269
+ },
3270
+ indicatorUpdate: {
3271
+ ...baseOptions.indicatorUpdate,
3272
+ ...options.indicatorUpdate ?? {}
3273
+ },
3274
+ session: {
3275
+ ...baseOptions.session,
3276
+ ...options.session ?? {}
3277
+ },
3278
+ history: {
3279
+ ...baseOptions.history,
3280
+ ...options.history ?? {}
3281
+ },
2986
3282
  datafeedOptions: {
2987
3283
  ...baseOptions.datafeedOptions,
2988
3284
  ...options.datafeedOptions ?? {}
@@ -3146,6 +3442,45 @@ function createChart(element, options = {}) {
3146
3442
  ...drawing.label === void 0 ? {} : { label: drawing.label }
3147
3443
  });
3148
3444
  let tradeMarkers = [];
3445
+ let timezoneSetting = mergedOptions.timezone ?? "local";
3446
+ let zoneClock = createZoneClock(timezoneSetting);
3447
+ let timeFormat = mergedOptions.timeFormat === "12h" ? "12h" : "24h";
3448
+ const DEFAULT_SESSION_OPTIONS = {
3449
+ spec: null,
3450
+ separators: true,
3451
+ highlightOutOfSession: false,
3452
+ outOfSessionColor: "rgba(120,132,158,0.07)"
3453
+ };
3454
+ let sessionOptions = {
3455
+ ...DEFAULT_SESSION_OPTIONS,
3456
+ ...mergedOptions.session ?? {}
3457
+ };
3458
+ let compiledSession = compileSession(sessionOptions.spec);
3459
+ const isHour12 = () => timeFormat === "12h";
3460
+ const formatClock = (time) => zoneClock.formatTime(time.getTime(), isHour12());
3461
+ let barMarks = [];
3462
+ let timescaleMarks = [];
3463
+ let markRegions = [];
3464
+ let hoveredMarkKey = null;
3465
+ let markClickHandler = null;
3466
+ let markHoverHandler = null;
3467
+ let alerts = [];
3468
+ let generatedAlertId = 1;
3469
+ let alertTriggerHandler = null;
3470
+ let alertActionHandler = null;
3471
+ let alertRegions = [];
3472
+ let alertDragState = null;
3473
+ let hoveredAlertId = null;
3474
+ let replay = null;
3475
+ let replayStateHandler = null;
3476
+ const historyEnabled = mergedOptions.history?.enabled !== false;
3477
+ const historyLimit = Math.max(1, Math.floor(Number(mergedOptions.history?.limit ?? 100)) || 100);
3478
+ let historyPast = [];
3479
+ let historyFuture = [];
3480
+ let historyBaseline = "";
3481
+ let historySuspended = 0;
3482
+ let undoRedoStateHandler = null;
3483
+ let lastUndoRedoSignature = "";
3149
3484
  let indicators = (options.indicators ?? []).map((indicator) => normalizeIndicatorState(indicator));
3150
3485
  let drawings = dedupeDrawingIds(
3151
3486
  (options.drawings ?? []).map((drawing) => normalizeDrawingState(drawing))
@@ -3194,9 +3529,168 @@ function createChart(element, options = {}) {
3194
3529
  let selectedDrawingId = null;
3195
3530
  const drawingToolDefaults = /* @__PURE__ */ new Map();
3196
3531
  const getDrawingToolDefaults = (tool) => drawingToolDefaults.get(tool) ?? {};
3532
+ const historyDoc = () => JSON.stringify(drawings.map((drawing) => serializeDrawing(drawing)));
3533
+ const describeHistoryChange = (beforeDoc, afterDoc) => {
3534
+ let before = [];
3535
+ let after = [];
3536
+ try {
3537
+ before = JSON.parse(beforeDoc);
3538
+ after = JSON.parse(afterDoc);
3539
+ } catch {
3540
+ return "Edit drawings";
3541
+ }
3542
+ const label = (type) => type ? type.replace(/-/g, " ") : "drawing";
3543
+ if (after.length > before.length) {
3544
+ const beforeIds = new Set(before.map((drawing) => drawing.id));
3545
+ const added = after.find((drawing) => !beforeIds.has(drawing.id));
3546
+ return `Add ${label(added?.type)}`;
3547
+ }
3548
+ if (after.length < before.length) {
3549
+ if (after.length === 0 && before.length > 1) return "Clear drawings";
3550
+ const afterIds = new Set(after.map((drawing) => drawing.id));
3551
+ const removed = before.find((drawing) => !afterIds.has(drawing.id));
3552
+ return `Delete ${label(removed?.type)}`;
3553
+ }
3554
+ const changed = after.find((drawing, index) => JSON.stringify(drawing) !== JSON.stringify(before[index]));
3555
+ return `Edit ${label(changed?.type)}`;
3556
+ };
3557
+ const getUndoRedoState = () => ({
3558
+ canUndo: historyPast.length > 0,
3559
+ canRedo: historyFuture.length > 0,
3560
+ undoLabel: historyPast.length > 0 ? historyPast[historyPast.length - 1].label : null,
3561
+ redoLabel: historyFuture.length > 0 ? historyFuture[historyFuture.length - 1].label : null
3562
+ });
3563
+ const emitUndoRedoState = () => {
3564
+ const state = getUndoRedoState();
3565
+ const signature = `${state.canUndo}|${state.canRedo}|${state.undoLabel}|${state.redoLabel}`;
3566
+ if (signature === lastUndoRedoSignature) return;
3567
+ lastUndoRedoSignature = signature;
3568
+ undoRedoStateHandler?.(state);
3569
+ };
3570
+ const commitHistory = () => {
3571
+ if (!historyEnabled || historySuspended > 0) return;
3572
+ if (drawingDragState !== null) return;
3573
+ const next = historyDoc();
3574
+ if (next === historyBaseline) return;
3575
+ historyPast.push({ label: describeHistoryChange(historyBaseline, next), doc: historyBaseline });
3576
+ if (historyPast.length > historyLimit) historyPast.shift();
3577
+ historyFuture = [];
3578
+ historyBaseline = next;
3579
+ emitUndoRedoState();
3580
+ };
3581
+ const resetHistoryBaseline = () => {
3582
+ historyBaseline = historyDoc();
3583
+ };
3584
+ const applyHistoryDoc = (doc) => {
3585
+ let parsed;
3586
+ try {
3587
+ parsed = JSON.parse(doc);
3588
+ } catch {
3589
+ return;
3590
+ }
3591
+ historySuspended += 1;
3592
+ try {
3593
+ drawings = dedupeDrawingIds(parsed.map((drawing) => normalizeDrawingState(drawing)));
3594
+ if (selectedDrawingId !== null && !drawings.some((drawing) => drawing.id === selectedDrawingId)) {
3595
+ selectedDrawingId = null;
3596
+ }
3597
+ draftDrawing = null;
3598
+ drawingDragState = null;
3599
+ historyBaseline = doc;
3600
+ emitDrawingsChange();
3601
+ scheduleDraw({ updateAutoScale: true });
3602
+ } finally {
3603
+ historySuspended -= 1;
3604
+ }
3605
+ };
3606
+ const undo = () => {
3607
+ if (!historyEnabled || historyPast.length === 0) return false;
3608
+ const entry = historyPast.pop();
3609
+ historyFuture.push({ label: entry.label, doc: historyBaseline });
3610
+ applyHistoryDoc(entry.doc);
3611
+ emitUndoRedoState();
3612
+ return true;
3613
+ };
3614
+ const redo = () => {
3615
+ if (!historyEnabled || historyFuture.length === 0) return false;
3616
+ const entry = historyFuture.pop();
3617
+ historyPast.push({ label: entry.label, doc: historyBaseline });
3618
+ applyHistoryDoc(entry.doc);
3619
+ emitUndoRedoState();
3620
+ return true;
3621
+ };
3622
+ const clearHistory = () => {
3623
+ historyPast = [];
3624
+ historyFuture = [];
3625
+ resetHistoryBaseline();
3626
+ emitUndoRedoState();
3627
+ };
3197
3628
  const emitDrawingsChange = () => {
3629
+ commitHistory();
3198
3630
  drawingsChangeHandler?.(drawings.map((drawing) => serializeDrawing(drawing)));
3199
3631
  };
3632
+ const normalizeAlert = (alert) => ({
3633
+ id: alert.id ?? `alert-${generatedAlertId++}`,
3634
+ price: Number(alert.price),
3635
+ condition: alert.condition ?? "crossing",
3636
+ label: alert.label ?? "",
3637
+ color: alert.color ?? "#f7a600",
3638
+ active: alert.active !== false,
3639
+ triggered: alert.triggered === true,
3640
+ once: alert.once !== false,
3641
+ draggable: alert.draggable !== false,
3642
+ lastPrice: null
3643
+ });
3644
+ const serializeAlert = (alert) => ({
3645
+ id: alert.id,
3646
+ price: alert.price,
3647
+ condition: alert.condition,
3648
+ label: alert.label,
3649
+ color: alert.color,
3650
+ active: alert.active,
3651
+ triggered: alert.triggered,
3652
+ once: alert.once,
3653
+ draggable: alert.draggable
3654
+ });
3655
+ const evaluateAlerts = (price, timeMs, index) => {
3656
+ if (alerts.length === 0 || !Number.isFinite(price)) return;
3657
+ for (const alert of alerts) {
3658
+ if (!alert.active || alert.triggered || !Number.isFinite(alert.price)) {
3659
+ alert.lastPrice = price;
3660
+ continue;
3661
+ }
3662
+ const previous = alert.lastPrice;
3663
+ let hit = false;
3664
+ switch (alert.condition) {
3665
+ case "greater":
3666
+ hit = price > alert.price;
3667
+ break;
3668
+ case "less":
3669
+ hit = price < alert.price;
3670
+ break;
3671
+ case "crossing-up":
3672
+ hit = previous !== null && previous < alert.price && price >= alert.price;
3673
+ break;
3674
+ case "crossing-down":
3675
+ hit = previous !== null && previous > alert.price && price <= alert.price;
3676
+ break;
3677
+ default:
3678
+ hit = previous !== null && (previous < alert.price && price >= alert.price || previous > alert.price && price <= alert.price);
3679
+ break;
3680
+ }
3681
+ alert.lastPrice = price;
3682
+ if (!hit) continue;
3683
+ alert.triggered = true;
3684
+ if (alert.once) alert.active = false;
3685
+ alertTriggerHandler?.({ alert: serializeAlert(alert), price, timeMs, index });
3686
+ scheduleDraw({ overlayOnly: false });
3687
+ }
3688
+ };
3689
+ const evaluateAlertsFromLatestBar = () => {
3690
+ if (alerts.length === 0 || data.length === 0) return;
3691
+ const last = data[data.length - 1];
3692
+ evaluateAlerts(last.c, last.time.getTime(), data.length - 1);
3693
+ };
3200
3694
  const orderWidgetWidthById = /* @__PURE__ */ new Map();
3201
3695
  const orderPriceTagWidthById = /* @__PURE__ */ new Map();
3202
3696
  let xCenter = 0;
@@ -3242,6 +3736,7 @@ function createChart(element, options = {}) {
3242
3736
  let crosshairPoint = null;
3243
3737
  let doubleClickEnabled = mergedOptions.doubleClickEnabled;
3244
3738
  let doubleClickAction = mergedOptions.doubleClickAction;
3739
+ setIndicatorLiveThrottleMs(mergedOptions.indicatorUpdate?.liveThrottleMs ?? 80);
3245
3740
  let noOverlappingLineLabels = DEFAULT_LABELS_OPTIONS.noOverlapping;
3246
3741
  let rightAxisLabelSlots = [];
3247
3742
  let plotLabelSlots = [];
@@ -3368,6 +3863,9 @@ function createChart(element, options = {}) {
3368
3863
  let overlayLayout = null;
3369
3864
  const margin = { top: 16, right: 72, bottom: 26, left: 12 };
3370
3865
  let cachedRightMargin = margin.right;
3866
+ let rightMarginShrinkPendingSince = null;
3867
+ const RIGHT_MARGIN_SHRINK_DELAY_MS = 600;
3868
+ const RIGHT_MARGIN_SHRINK_EPSILON_PX = 2;
3371
3869
  const minVisibleBars = Math.max(1, Math.floor(mergedOptions.minVisibleBars));
3372
3870
  const maxVisibleBars = Math.max(minVisibleBars, Math.floor(mergedOptions.maxVisibleBars));
3373
3871
  const maxPanBars = Math.max(0, Math.floor(mergedOptions.maxPanBars));
@@ -3483,21 +3981,69 @@ function createChart(element, options = {}) {
3483
3981
  Math.min(highCenter, count + maxPanBars)
3484
3982
  );
3485
3983
  };
3486
- const fitXViewport = () => {
3487
- const count = data.length;
3488
- if (count === 0) {
3489
- xCenter = 0;
3490
- xSpan = 60;
3491
- return;
3984
+ let viewportTweenRaf = null;
3985
+ const cancelViewportTween = () => {
3986
+ if (viewportTweenRaf !== null) {
3987
+ cancelAnimationFrame(viewportTweenRaf);
3988
+ viewportTweenRaf = null;
3989
+ }
3990
+ };
3991
+ const prefersReducedMotion = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
3992
+ const animateViewportTo = (targetCenter, targetSpan, onComplete) => {
3993
+ const animation = mergedOptions.animation ?? DEFAULT_OPTIONS.animation;
3994
+ const duration = Math.max(0, animation?.durationMs ?? 260);
3995
+ const startCenter = xCenter;
3996
+ const startSpan = xSpan;
3997
+ const centerDelta = targetCenter - startCenter;
3998
+ const spanRatio = startSpan > 0 ? targetSpan / startSpan : 1;
3999
+ const negligible = Math.abs(centerDelta) < 0.5 && Math.abs(spanRatio - 1) < 0.01;
4000
+ if (animation?.viewportTransitions === false || duration === 0 || negligible || prefersReducedMotion() || typeof requestAnimationFrame !== "function") {
4001
+ xCenter = targetCenter;
4002
+ xSpan = targetSpan;
4003
+ clampXViewport();
4004
+ onComplete?.();
4005
+ emitViewportChange();
4006
+ scheduleDraw();
4007
+ return false;
3492
4008
  }
4009
+ cancelViewportTween();
4010
+ const startedAt = performance.now();
4011
+ const step = (now) => {
4012
+ const t = clamp((now - startedAt) / duration, 0, 1);
4013
+ const eased = 1 - Math.pow(1 - t, 3);
4014
+ xCenter = startCenter + centerDelta * eased;
4015
+ xSpan = startSpan * Math.pow(spanRatio, eased);
4016
+ clampXViewport();
4017
+ scheduleDraw();
4018
+ if (t < 1) {
4019
+ viewportTweenRaf = requestAnimationFrame(step);
4020
+ return;
4021
+ }
4022
+ viewportTweenRaf = null;
4023
+ xCenter = targetCenter;
4024
+ xSpan = targetSpan;
4025
+ clampXViewport();
4026
+ onComplete?.();
4027
+ emitViewportChange();
4028
+ scheduleDraw();
4029
+ };
4030
+ viewportTweenRaf = requestAnimationFrame(step);
4031
+ return true;
4032
+ };
4033
+ const getFitXTarget = () => {
4034
+ const count = data.length;
4035
+ if (count === 0) return { center: 0, span: 60 };
3493
4036
  const maxSpan = Math.min(maxVisibleBars, Math.max(minVisibleBars, count + maxPanBars * 2));
3494
- const requestedVisibleBars = clamp(Math.floor(mergedOptions.initialVisibleBars), minVisibleBars, maxSpan);
3495
- xSpan = requestedVisibleBars;
3496
- if (mergedOptions.initialViewport === "center") {
3497
- xCenter = count / 2;
3498
- } else {
3499
- xCenter = count - xSpan / 2 + rightEdgePaddingBars;
3500
- }
4037
+ const span = clamp(Math.floor(mergedOptions.initialVisibleBars), minVisibleBars, maxSpan);
4038
+ return {
4039
+ center: mergedOptions.initialViewport === "center" ? count / 2 : count - span / 2 + rightEdgePaddingBars,
4040
+ span
4041
+ };
4042
+ };
4043
+ const fitXViewport = () => {
4044
+ const target = getFitXTarget();
4045
+ xSpan = target.span;
4046
+ xCenter = target.center;
3501
4047
  clampXViewport();
3502
4048
  };
3503
4049
  const getYBounds = () => {
@@ -3647,6 +4193,7 @@ function createChart(element, options = {}) {
3647
4193
  };
3648
4194
  const resetRightMarginCache = () => {
3649
4195
  cachedRightMargin = margin.right;
4196
+ rightMarginShrinkPendingSince = null;
3650
4197
  };
3651
4198
  const parseData = (nextData) => {
3652
4199
  const dedupedByTime = /* @__PURE__ */ new Map();
@@ -4009,40 +4556,13 @@ function createChart(element, options = {}) {
4009
4556
  datafeedUnsubscribe = typeof unsubscribe === "function" ? unsubscribe : null;
4010
4557
  };
4011
4558
  const formatHoverTimeLabel = (time, mode) => {
4012
- if (mode === "time") {
4013
- return time.toLocaleTimeString(void 0, {
4014
- hour: "2-digit",
4015
- minute: "2-digit",
4016
- hour12: false
4017
- });
4018
- }
4019
- if (mode === "datetime") {
4020
- return time.toLocaleString(void 0, {
4021
- month: "short",
4022
- day: "numeric",
4023
- hour: "2-digit",
4024
- minute: "2-digit",
4025
- hour12: false
4026
- });
4027
- }
4028
- if (mode === "date") {
4029
- return time.toLocaleDateString(void 0, {
4030
- month: "short",
4031
- day: "numeric"
4032
- });
4033
- }
4559
+ const ms = time.getTime();
4560
+ if (mode === "time") return formatClock(time);
4561
+ if (mode === "datetime") return `${zoneClock.formatDayMonth(ms)}, ${formatClock(time)}`;
4562
+ if (mode === "date") return zoneClock.formatDayMonth(ms);
4034
4563
  const stepMs = getTimeStepMs();
4035
- if (stepMs < 24 * 60 * 60 * 1e3) {
4036
- return time.toLocaleTimeString(void 0, {
4037
- hour: "2-digit",
4038
- minute: "2-digit",
4039
- hour12: false
4040
- });
4041
- }
4042
- return time.toLocaleDateString(void 0, {
4043
- month: "short",
4044
- day: "numeric"
4045
- });
4564
+ if (stepMs < 24 * 60 * 60 * 1e3) return formatClock(time);
4565
+ return zoneClock.formatDayMonth(ms);
4046
4566
  };
4047
4567
  const drawText = (text, x, y, align = "left", baseline = "alphabetic", color = mergedOptions.axis?.textColor ?? mergedOptions.axisColor) => {
4048
4568
  ctx.fillStyle = color;
@@ -4139,6 +4659,86 @@ function createChart(element, options = {}) {
4139
4659
  labelTextColorOn(mergedLine.labelBackgroundColor, mergedLine.labelTextColor)
4140
4660
  );
4141
4661
  };
4662
+ const drawBellGlyph = (x, y, size, color) => {
4663
+ const w = size;
4664
+ const h = size;
4665
+ ctx.save();
4666
+ ctx.strokeStyle = color;
4667
+ ctx.fillStyle = color;
4668
+ ctx.lineWidth = 1.2;
4669
+ ctx.lineJoin = "round";
4670
+ ctx.beginPath();
4671
+ ctx.moveTo(x - w * 0.42, y + h * 0.28);
4672
+ ctx.quadraticCurveTo(x - w * 0.3, y + h * 0.2, x - w * 0.3, y - h * 0.05);
4673
+ ctx.quadraticCurveTo(x - w * 0.3, y - h * 0.46, x, y - h * 0.46);
4674
+ ctx.quadraticCurveTo(x + w * 0.3, y - h * 0.46, x + w * 0.3, y - h * 0.05);
4675
+ ctx.quadraticCurveTo(x + w * 0.3, y + h * 0.2, x + w * 0.42, y + h * 0.28);
4676
+ ctx.closePath();
4677
+ ctx.stroke();
4678
+ ctx.beginPath();
4679
+ ctx.arc(x, y + h * 0.42, Math.max(1, w * 0.1), 0, Math.PI * 2);
4680
+ ctx.fill();
4681
+ ctx.restore();
4682
+ };
4683
+ const drawAlertLine = (alert, yFromPrice, chartLeft, chartTop, chartRight, chartBottom) => {
4684
+ const lineY = getLineY(alert.price, yFromPrice, chartTop, chartBottom, false);
4685
+ if (lineY === null) return;
4686
+ const dim = alert.triggered || !alert.active;
4687
+ const color = alert.color || "#f7a600";
4688
+ const axis = resolvedAxis;
4689
+ ctx.save();
4690
+ ctx.globalAlpha = dim ? 0.5 : 1;
4691
+ ctx.strokeStyle = color;
4692
+ ctx.lineWidth = 1;
4693
+ applyDashPattern("dashed", dashPatterns.dotted, dashPatterns.dashed);
4694
+ ctx.beginPath();
4695
+ ctx.moveTo(crisp(chartLeft), crisp(lineY));
4696
+ ctx.lineTo(crisp(chartRight), crisp(lineY));
4697
+ ctx.stroke();
4698
+ ctx.restore();
4699
+ const fontSize = Math.max(8, axis.fontSize);
4700
+ ctx.font = `${fontSize}px ${mergedOptions.fontFamily}`;
4701
+ const text = alert.label ? `${formatPrice(alert.price)} ${alert.label}` : formatPrice(alert.price);
4702
+ const paddingX = 6;
4703
+ const bellWidth = fontSize + 2;
4704
+ const labelHeight = 20;
4705
+ const hovered = hoveredAlertId === alert.id;
4706
+ const removeWidth = hovered ? labelHeight : 0;
4707
+ const contentWidth = Math.ceil(measureTextWidth(text)) + bellWidth + paddingX * 3 + removeWidth;
4708
+ const labelWidth = getRightAxisLabelWidth(chartRight, contentWidth);
4709
+ const labelX = getRightAxisLabelX(chartRight);
4710
+ const labelY = placeRightAxisLabel(lineY - labelHeight / 2, labelHeight, chartTop, chartBottom - labelHeight);
4711
+ ctx.save();
4712
+ ctx.globalAlpha = dim ? 0.6 : 1;
4713
+ ctx.fillStyle = color;
4714
+ fillRoundedRect(Math.round(labelX), Math.round(labelY), labelWidth, labelHeight, 3);
4715
+ const inkColor = labelTextColorOn(color, "#101114");
4716
+ drawBellGlyph(labelX + paddingX + bellWidth / 2, labelY + labelHeight / 2, fontSize, inkColor);
4717
+ drawText(text, labelX + paddingX * 2 + bellWidth, labelY + labelHeight / 2, "left", "middle", inkColor);
4718
+ if (hovered) {
4719
+ const removeX = labelX + labelWidth - removeWidth;
4720
+ ctx.strokeStyle = inkColor;
4721
+ ctx.lineWidth = 1.4;
4722
+ const inset = 6;
4723
+ ctx.beginPath();
4724
+ ctx.moveTo(removeX + inset, labelY + inset);
4725
+ ctx.lineTo(removeX + removeWidth - inset, labelY + labelHeight - inset);
4726
+ ctx.moveTo(removeX + removeWidth - inset, labelY + inset);
4727
+ ctx.lineTo(removeX + inset, labelY + labelHeight - inset);
4728
+ ctx.stroke();
4729
+ alertRegions.push({
4730
+ id: alert.id,
4731
+ x: removeX,
4732
+ y: labelY,
4733
+ width: removeWidth,
4734
+ height: labelHeight,
4735
+ part: "remove"
4736
+ });
4737
+ }
4738
+ ctx.restore();
4739
+ alertRegions.push({ id: alert.id, x: labelX, y: labelY, width: labelWidth - removeWidth, height: labelHeight, part: "line" });
4740
+ alertRegions.push({ id: alert.id, x: chartLeft, y: lineY - 5, width: chartRight - chartLeft, height: 10, part: "line" });
4741
+ };
4142
4742
  const drawOrderLine = (line, yFromPrice, chartLeft, chartTop, chartRight, chartBottom) => {
4143
4743
  const mergedLine = { ...DEFAULT_ORDER_LINE_OPTIONS, ...line };
4144
4744
  const renderPrice = mergedLine.behavior === "follow" && Number.isFinite(mergedLine.followPrice) ? mergedLine.followPrice : mergedLine.price;
@@ -4423,6 +5023,37 @@ function createChart(element, options = {}) {
4423
5023
  ctx.closePath();
4424
5024
  ctx.stroke();
4425
5025
  };
5026
+ const drawMarkBadge = (x, y, radius, color, shape, label, textColor) => {
5027
+ ctx.save();
5028
+ ctx.fillStyle = color;
5029
+ ctx.beginPath();
5030
+ if (shape === "square") {
5031
+ ctx.rect(x - radius, y - radius, radius * 2, radius * 2);
5032
+ } else if (shape === "diamond") {
5033
+ ctx.moveTo(x, y - radius);
5034
+ ctx.lineTo(x + radius, y);
5035
+ ctx.lineTo(x, y + radius);
5036
+ ctx.lineTo(x - radius, y);
5037
+ ctx.closePath();
5038
+ } else if (shape === "flag") {
5039
+ ctx.rect(x - radius * 0.16, y - radius, radius * 0.32, radius * 2);
5040
+ ctx.moveTo(x + radius * 0.16, y - radius);
5041
+ ctx.lineTo(x + radius * 1.35, y - radius * 0.45);
5042
+ ctx.lineTo(x + radius * 0.16, y + radius * 0.1);
5043
+ ctx.closePath();
5044
+ } else {
5045
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
5046
+ }
5047
+ ctx.fill();
5048
+ if (label && shape !== "flag") {
5049
+ ctx.fillStyle = textColor;
5050
+ ctx.font = `600 ${Math.max(8, Math.round(radius * 1.2))}px ${mergedOptions.fontFamily}`;
5051
+ ctx.textAlign = "center";
5052
+ ctx.textBaseline = "middle";
5053
+ ctx.fillText(label.slice(0, 2), x, y + 0.5);
5054
+ }
5055
+ ctx.restore();
5056
+ };
4426
5057
  const setCrosshairPoint = (point) => {
4427
5058
  const current = crosshairPoint;
4428
5059
  if (point === null && current === null) {
@@ -4470,6 +5101,8 @@ function createChart(element, options = {}) {
4470
5101
  const shouldUpdateAutoScale = options2.updateAutoScale ?? true;
4471
5102
  orderActionRegions = [];
4472
5103
  orderDragRegions = [];
5104
+ alertRegions = [];
5105
+ markRegions = [];
4473
5106
  crosshairPriceActionRegion = null;
4474
5107
  const pixelRatio = getPixelRatio();
4475
5108
  canvas.style.width = `${width}px`;
@@ -4552,7 +5185,23 @@ function createChart(element, options = {}) {
4552
5185
  }
4553
5186
  const maxRightMargin = Math.max(margin.right, width - margin.left - 160);
4554
5187
  const nextRightMargin = Math.min(maxRightMargin, Math.max(margin.right, Math.ceil(required + 1)));
4555
- cachedRightMargin = Math.max(cachedRightMargin, nextRightMargin);
5188
+ if (nextRightMargin >= cachedRightMargin) {
5189
+ cachedRightMargin = nextRightMargin;
5190
+ rightMarginShrinkPendingSince = null;
5191
+ } else if (cachedRightMargin - nextRightMargin > RIGHT_MARGIN_SHRINK_EPSILON_PX) {
5192
+ const now = performance.now();
5193
+ if (rightMarginShrinkPendingSince === null) {
5194
+ rightMarginShrinkPendingSince = now;
5195
+ }
5196
+ if (now - rightMarginShrinkPendingSince >= RIGHT_MARGIN_SHRINK_DELAY_MS) {
5197
+ cachedRightMargin = nextRightMargin;
5198
+ rightMarginShrinkPendingSince = null;
5199
+ } else {
5200
+ scheduleDraw({ updateAutoScale: false });
5201
+ }
5202
+ } else {
5203
+ rightMarginShrinkPendingSince = null;
5204
+ }
4556
5205
  return Math.min(maxRightMargin, cachedRightMargin);
4557
5206
  };
4558
5207
  const rightMargin = estimateRightMargin();
@@ -5725,12 +6374,14 @@ function createChart(element, options = {}) {
5725
6374
  if (!prevTime) {
5726
6375
  weight = 7;
5727
6376
  } else {
5728
- if (prevTime.getFullYear() !== time.getFullYear()) weight = 9;
5729
- else if (prevTime.getMonth() !== time.getMonth()) weight = 8;
5730
- else if (prevTime.getDate() !== time.getDate()) weight = 7;
6377
+ const prev = zoneClock.parts(prevTime.getTime());
6378
+ const current = zoneClock.parts(time.getTime());
6379
+ if (prev.year !== current.year) weight = 9;
6380
+ else if (prev.month !== current.month) weight = 8;
6381
+ else if (prev.day !== current.day) weight = 7;
5731
6382
  else {
5732
- const prevMinutes = prevTime.getHours() * 60 + prevTime.getMinutes();
5733
- const curMinutes = time.getHours() * 60 + time.getMinutes();
6383
+ const prevMinutes = prev.hour * 60 + prev.minute;
6384
+ const curMinutes = current.hour * 60 + current.minute;
5734
6385
  const crossed = (span) => Math.floor(prevMinutes / span) !== Math.floor(curMinutes / span);
5735
6386
  if (crossed(360)) weight = 6;
5736
6387
  else if (crossed(60)) weight = 5;
@@ -5776,31 +6427,64 @@ function createChart(element, options = {}) {
5776
6427
  ctx.stroke();
5777
6428
  ctx.restore();
5778
6429
  }
5779
- if (grid.sessionSeparators && data.length > 1) {
6430
+ const sessionSeparatorsOn = grid.sessionSeparators || compiledSession !== null && sessionOptions.separators !== false;
6431
+ const intradaySeries = (() => {
6432
+ if (data.length < 2) return false;
6433
+ const probe = Math.min(Math.max(1, startIndex), data.length - 1);
6434
+ const delta = Math.abs(data[probe].time.getTime() - data[probe - 1].time.getTime());
6435
+ return delta > 0 && delta < 0.9 * 864e5;
6436
+ })();
6437
+ if (compiledSession && sessionOptions.highlightOutOfSession && intradaySeries && data.length > 0) {
6438
+ const session = compiledSession;
6439
+ ctx.save();
6440
+ ctx.fillStyle = sessionOptions.outOfSessionColor;
6441
+ let runStart = null;
6442
+ const flushRun = (endExclusive) => {
6443
+ if (runStart === null) return;
6444
+ const left = chartLeft + (runStart - xStart) / xSpan * chartWidth;
6445
+ const right = chartLeft + (endExclusive - xStart) / xSpan * chartWidth;
6446
+ const clampedLeft = Math.max(chartLeft, left);
6447
+ const clampedRight = Math.min(chartRight, right);
6448
+ if (clampedRight > clampedLeft) {
6449
+ ctx.fillRect(clampedLeft, chartTop, clampedRight - clampedLeft, fullChartBottom - chartTop);
6450
+ }
6451
+ runStart = null;
6452
+ };
6453
+ for (let index = Math.max(0, startIndex); index <= endIndex; index += 1) {
6454
+ const point = data[index];
6455
+ if (!point) continue;
6456
+ const inSession = sessionInfoAt(session, point.time.getTime()).inSession;
6457
+ if (!inSession) {
6458
+ if (runStart === null) runStart = index;
6459
+ } else {
6460
+ flushRun(index);
6461
+ }
6462
+ }
6463
+ flushRun(endIndex + 1);
6464
+ ctx.restore();
6465
+ }
6466
+ if (sessionSeparatorsOn && data.length > 1 && intradaySeries) {
5780
6467
  const first = Math.max(1, startIndex);
5781
- const barDeltaMs = data.length > 1 ? Math.abs(data[Math.min(first, data.length - 1)].time.getTime() - data[Math.min(first, data.length - 1) - 1].time.getTime()) : 0;
5782
- if (barDeltaMs > 0 && barDeltaMs < 0.9 * 864e5) {
5783
- const SESSION_ANCHOR_MS = 22 * 36e5;
5784
- const sessionDayOf = (ms) => Math.floor((ms - SESSION_ANCHOR_MS) / 864e5);
5785
- ctx.save();
5786
- ctx.globalAlpha = clamp(grid.sessionSeparatorOpacity, 0, 1);
5787
- ctx.strokeStyle = grid.sessionSeparatorColor;
5788
- ctx.lineWidth = 1;
5789
- for (let index = first; index <= endIndex; index += 1) {
5790
- const point = data[index];
5791
- const prev = data[index - 1];
5792
- if (!point || !prev) continue;
5793
- if (sessionDayOf(point.time.getTime()) === sessionDayOf(prev.time.getTime())) {
5794
- continue;
5795
- }
5796
- const x = chartLeft + (index - xStart) / xSpan * chartWidth;
5797
- ctx.beginPath();
5798
- ctx.moveTo(crisp(x), crisp(chartTop));
5799
- ctx.lineTo(crisp(x), crisp(fullChartBottom));
5800
- ctx.stroke();
6468
+ const session = compiledSession;
6469
+ const dayOf = (ms) => session ? sessionInfoAt(session, ms).sessionDay : zoneClock.dayKey(ms);
6470
+ ctx.save();
6471
+ ctx.globalAlpha = clamp(grid.sessionSeparatorOpacity, 0, 1);
6472
+ ctx.strokeStyle = grid.sessionSeparatorColor;
6473
+ ctx.lineWidth = 1;
6474
+ for (let index = first; index <= endIndex; index += 1) {
6475
+ const point = data[index];
6476
+ const prev = data[index - 1];
6477
+ if (!point || !prev) continue;
6478
+ if (dayOf(point.time.getTime()) === dayOf(prev.time.getTime())) {
6479
+ continue;
5801
6480
  }
5802
- ctx.restore();
6481
+ const x = chartLeft + (index - xStart) / xSpan * chartWidth;
6482
+ ctx.beginPath();
6483
+ ctx.moveTo(crisp(x), crisp(chartTop));
6484
+ ctx.lineTo(crisp(x), crisp(fullChartBottom));
6485
+ ctx.stroke();
5803
6486
  }
6487
+ ctx.restore();
5804
6488
  }
5805
6489
  const tickerOpts = mergedOptions.tickerLine ?? DEFAULT_OPTIONS.tickerLine;
5806
6490
  const useSmoothedCandle = tickerOpts.smoothing && smoothedTickerPrice !== null;
@@ -6381,6 +7065,35 @@ function createChart(element, options = {}) {
6381
7065
  ctx.textAlign = prevAlign;
6382
7066
  ctx.textBaseline = prevBaseline;
6383
7067
  }
7068
+ if (barMarks.length > 0 && data.length > 0) {
7069
+ const prevFont = ctx.font;
7070
+ const prevAlign = ctx.textAlign;
7071
+ const prevBaseline = ctx.textBaseline;
7072
+ ctx.textAlign = "center";
7073
+ ctx.textBaseline = "middle";
7074
+ for (const mark of barMarks) {
7075
+ const ms = typeof mark.time === "number" ? mark.time : Date.parse(String(mark.time));
7076
+ if (!Number.isFinite(ms)) continue;
7077
+ const index = findNearestIndexForTimeMs(ms);
7078
+ if (index === null) continue;
7079
+ const bar = data[index];
7080
+ if (!bar) continue;
7081
+ const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
7082
+ if (x < chartLeft - 20 || x > chartRight + 20) continue;
7083
+ const radius = Math.max(4, Number(mark.size) || 7);
7084
+ const above = mark.position === "above";
7085
+ const y = Number.isFinite(mark.price) ? yFromPrice(Number(mark.price)) : above ? yFromPrice(bar.h) - radius - 8 : yFromPrice(bar.l) + radius + 8;
7086
+ if (y < chartTop - radius || y > chartBottom + radius) continue;
7087
+ const key = mark.id ?? `bar:${index}:${mark.label ?? ""}`;
7088
+ const hovered = hoveredMarkKey === `bar|${key}`;
7089
+ const color = mark.color ?? "#f7a600";
7090
+ drawMarkBadge(x, y, hovered ? radius + 1.5 : radius, color, mark.shape ?? "circle", mark.label ?? "", mark.textColor ?? "#101114");
7091
+ markRegions.push({ kind: "bar", mark, id: key, x, y, radius: radius + 3 });
7092
+ }
7093
+ ctx.font = prevFont;
7094
+ ctx.textAlign = prevAlign;
7095
+ ctx.textBaseline = prevBaseline;
7096
+ }
6384
7097
  ctx.restore();
6385
7098
  const positionAxisGutter = Math.max(0, width - chartRight);
6386
7099
  if (positionAxisGutter > 0) {
@@ -6891,9 +7604,79 @@ function createChart(element, options = {}) {
6891
7604
  for (const priceLine of priceLines) {
6892
7605
  drawPriceLine(priceLine, yFromPrice, chartLeft, chartTop, chartRight, chartBottom);
6893
7606
  }
7607
+ for (const alert of alerts) {
7608
+ drawAlertLine(alert, yFromPrice, chartLeft, chartTop, chartRight, chartBottom);
7609
+ }
6894
7610
  for (const orderLine of orderLines) {
6895
7611
  drawOrderLine(orderLine, yFromPrice, chartLeft, chartTop, chartRight, chartBottom);
6896
7612
  }
7613
+ let dataLineBottom = chartTop;
7614
+ const dataLine = mergedOptions.dataLine;
7615
+ if (dataLine?.visible && data.length > 0) {
7616
+ const hoverIndex = crosshairPoint && getHitRegion(crosshairPoint.x, crosshairPoint.y) === "plot" ? indexFromCanvasX(crosshairPoint.x) : null;
7617
+ const barIndex = hoverIndex !== null && hoverIndex >= 0 && hoverIndex < data.length ? hoverIndex : data.length - 1;
7618
+ const bar = data[barIndex];
7619
+ if (bar) {
7620
+ const prevFont = ctx.font;
7621
+ const fontSize = Math.max(9, dataLine.fontSize || axis.fontSize);
7622
+ ctx.font = `${fontSize}px ${mergedOptions.fontFamily}`;
7623
+ ctx.textBaseline = "top";
7624
+ ctx.textAlign = "left";
7625
+ const symbolColor = dataLine.symbolColor || axis.textColor;
7626
+ const textColor = dataLine.textColor || labels.indicatorTextColor;
7627
+ const changeAbs = bar.c - bar.o;
7628
+ const changeColor = changeAbs >= 0 ? mergedOptions.upColor : mergedOptions.downColor;
7629
+ const rowY = chartTop + 6;
7630
+ let cursorX = chartLeft + 10;
7631
+ if (dataLine.statusColor) {
7632
+ const dotRadius = Math.max(2.5, fontSize / 4);
7633
+ ctx.fillStyle = dataLine.statusColor;
7634
+ ctx.beginPath();
7635
+ ctx.arc(cursorX + dotRadius, rowY + fontSize / 2, dotRadius, 0, Math.PI * 2);
7636
+ ctx.fill();
7637
+ cursorX += dotRadius * 2 + 6;
7638
+ }
7639
+ const headline = [dataLine.symbol, dataLine.interval, dataLine.exchange].filter((part) => part && part.length > 0).join(" \xB7 ");
7640
+ if (headline) {
7641
+ ctx.fillStyle = symbolColor;
7642
+ ctx.fillText(headline, cursorX, rowY);
7643
+ cursorX += measureTextWidth(headline) + 10;
7644
+ }
7645
+ if (dataLine.showOhlc !== false) {
7646
+ const ohlc = `O${formatPrice(bar.o)} H${formatPrice(bar.h)} L${formatPrice(bar.l)} C${formatPrice(bar.c)}`;
7647
+ ctx.fillStyle = changeColor;
7648
+ ctx.fillText(ohlc, cursorX, rowY);
7649
+ cursorX += measureTextWidth(ohlc) + 8;
7650
+ }
7651
+ if (dataLine.showChange !== false) {
7652
+ const pct = bar.o !== 0 ? changeAbs / bar.o * 100 : 0;
7653
+ const changeText = `${changeAbs >= 0 ? "+" : ""}${formatPrice(changeAbs)} (${changeAbs >= 0 ? "+" : ""}${pct.toFixed(2)}%)`;
7654
+ ctx.fillStyle = changeColor;
7655
+ ctx.fillText(changeText, cursorX, rowY);
7656
+ cursorX += measureTextWidth(changeText) + 10;
7657
+ }
7658
+ if (dataLine.showVolume && bar.v !== void 0) {
7659
+ const volumeText = `Vol ${bar.v >= 1e6 ? `${(bar.v / 1e6).toFixed(2)}M` : bar.v >= 1e3 ? `${(bar.v / 1e3).toFixed(1)}K` : String(Math.round(bar.v))}`;
7660
+ ctx.fillStyle = textColor;
7661
+ ctx.fillText(volumeText, cursorX, rowY);
7662
+ cursorX += measureTextWidth(volumeText) + 10;
7663
+ }
7664
+ for (const detail of dataLine.details ?? []) {
7665
+ const text = detail.label ? `${detail.label} ${detail.value}` : detail.value;
7666
+ const width2 = measureTextWidth(text);
7667
+ if (cursorX + width2 > chartRight - 4) break;
7668
+ if (detail.background) {
7669
+ ctx.fillStyle = detail.background;
7670
+ fillRoundedRect(cursorX - 4, rowY - 2, width2 + 8, fontSize + 4, 3);
7671
+ }
7672
+ ctx.fillStyle = detail.color || textColor;
7673
+ ctx.fillText(text, cursorX, rowY);
7674
+ cursorX += width2 + 12;
7675
+ }
7676
+ dataLineBottom = rowY + fontSize + 4;
7677
+ ctx.font = prevFont;
7678
+ }
7679
+ }
6897
7680
  if (labels.visible && (labels.showIndicatorNames || labels.showIndicatorValues)) {
6898
7681
  const isLegendInputValue = (value) => {
6899
7682
  if (typeof value === "number" || typeof value === "boolean") {
@@ -6922,7 +7705,7 @@ function createChart(element, options = {}) {
6922
7705
  const isRight = position === "top-right" || position === "bottom-right";
6923
7706
  const isBottom = position === "bottom-left" || position === "bottom-right";
6924
7707
  const legendX = isRight ? chartRight - offsetX : chartLeft + offsetX;
6925
- const legendY = isBottom ? chartBottom - offsetY : chartTop + offsetY;
7708
+ const legendY = isBottom ? chartBottom - offsetY : Math.max(chartTop + offsetY, dataLineBottom);
6926
7709
  drawText(legendText, legendX, legendY, isRight ? "right" : "left", isBottom ? "bottom" : "top", labels.indicatorTextColor);
6927
7710
  ctx.font = prevFont;
6928
7711
  }
@@ -6933,11 +7716,36 @@ function createChart(element, options = {}) {
6933
7716
  for (const tick of timeTicks) {
6934
7717
  const tickTime = getTimeForIndex(tick.index);
6935
7718
  if (!tickTime) continue;
6936
- const timeLabel = tick.weight >= 9 ? String(tickTime.getFullYear()) : tick.weight === 8 ? tickTime.toLocaleDateString(void 0, { month: "short" }) : tick.weight === 7 ? tickTime.toLocaleDateString(void 0, { month: "short", day: "numeric" }) : tickTime.toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit", hour12: false });
7719
+ const tickMs = tickTime.getTime();
7720
+ const timeLabel = tick.weight >= 9 ? zoneClock.formatYear(tickMs) : tick.weight === 8 ? zoneClock.formatMonth(tickMs) : tick.weight === 7 ? zoneClock.formatDayMonth(tickMs) : formatClock(tickTime);
6937
7721
  drawText(timeLabel, tick.x, (fullChartBottom + height) / 2, "center", "middle", xAxis.textColor);
6938
7722
  }
6939
7723
  ctx.font = prevFont;
6940
7724
  }
7725
+ if (timescaleMarks.length > 0 && data.length > 0) {
7726
+ const radius = 6;
7727
+ const markY = fullChartBottom - radius - 2;
7728
+ for (const mark of timescaleMarks) {
7729
+ const ms = typeof mark.time === "number" ? mark.time : Date.parse(String(mark.time));
7730
+ if (!Number.isFinite(ms)) continue;
7731
+ const index = findNearestIndexForTimeMs(ms);
7732
+ if (index === null) continue;
7733
+ const x = chartLeft + (index + 0.5 - xStart) / xSpan * chartWidth;
7734
+ if (x < chartLeft - 10 || x > chartRight + 10) continue;
7735
+ const key = mark.id ?? `ts:${index}:${mark.label ?? ""}`;
7736
+ const hovered = hoveredMarkKey === `timescale|${key}`;
7737
+ drawMarkBadge(
7738
+ x,
7739
+ markY,
7740
+ hovered ? radius + 1.5 : radius,
7741
+ mark.color ?? "#5b8def",
7742
+ mark.shape ?? "circle",
7743
+ mark.label ?? "",
7744
+ mark.textColor ?? "#0b1220"
7745
+ );
7746
+ markRegions.push({ kind: "timescale", mark, id: key, x, y: markY, radius: radius + 3 });
7747
+ }
7748
+ }
6941
7749
  if (labels.visible && labels.showCountdownToBarClose && lastPoint) {
6942
7750
  const stepMs = getTimeStepMs();
6943
7751
  const nowMs = Date.now() + (Number(mergedOptions.clockOffsetMs) || 0);
@@ -7463,27 +8271,24 @@ function createChart(element, options = {}) {
7463
8271
  scheduleDraw();
7464
8272
  };
7465
8273
  const fitContent = () => {
7466
- fitXViewport();
7467
- updateFollowLatest(true);
7468
- emitViewportChange();
7469
- scheduleDraw();
8274
+ const target = getFitXTarget();
8275
+ animateViewportTo(target.center, target.span, () => updateFollowLatest(true));
7470
8276
  };
7471
8277
  const resetViewport = () => {
7472
8278
  cancelKineticPan();
7473
- fitXViewport();
8279
+ const target = getFitXTarget();
7474
8280
  resetYViewport();
7475
- updateFollowLatest(true);
7476
- emitViewportChange();
7477
- scheduleDraw();
8281
+ animateViewportTo(target.center, target.span, () => updateFollowLatest(true));
7478
8282
  };
7479
8283
  const isFollowingLatest = () => followLatest;
7480
8284
  const setFollowingLatest = (follow) => {
7481
8285
  if (follow && data.length > 0) {
7482
8286
  cancelKineticPan();
7483
- xCenter = data.length - xSpan / 2 + rightEdgePaddingBars;
7484
- clampXViewport();
7485
- updateFollowLatest(true);
7486
- scheduleDraw();
8287
+ animateViewportTo(
8288
+ data.length - xSpan / 2 + rightEdgePaddingBars,
8289
+ xSpan,
8290
+ () => updateFollowLatest(true)
8291
+ );
7487
8292
  } else {
7488
8293
  updateFollowLatest(follow);
7489
8294
  }
@@ -7566,6 +8371,22 @@ function createChart(element, options = {}) {
7566
8371
  (region) => x >= region.x && x <= region.x + region.width && y >= region.y && y <= region.y + region.height
7567
8372
  );
7568
8373
  };
8374
+ const getAlertRegionAt = (x, y) => {
8375
+ const hits = alertRegions.filter(
8376
+ (region) => x >= region.x && x <= region.x + region.width && y >= region.y && y <= region.y + region.height
8377
+ );
8378
+ if (hits.length === 0) return null;
8379
+ return hits.find((region) => region.part === "remove") ?? hits[0];
8380
+ };
8381
+ const getMarkAt = (x, y) => {
8382
+ const tolerance = touchInputActive ? Math.max(1, mergedOptions.touch?.hitToleranceScale ?? 2.2) : 1;
8383
+ for (let index = markRegions.length - 1; index >= 0; index -= 1) {
8384
+ const region = markRegions[index];
8385
+ const reach = region.radius * tolerance;
8386
+ if (Math.abs(x - region.x) <= reach && Math.abs(y - region.y) <= reach) return region;
8387
+ }
8388
+ return null;
8389
+ };
7569
8390
  const getCrosshairPriceActionRegion = (x, y) => {
7570
8391
  if (!crosshairPriceActionRegion) {
7571
8392
  return null;
@@ -8725,7 +9546,10 @@ function createChart(element, options = {}) {
8725
9546
  pointerDownInfo = null;
8726
9547
  orderDragState = null;
8727
9548
  actionDragState = null;
8728
- drawingDragState = null;
9549
+ if (drawingDragState) {
9550
+ drawingDragState = null;
9551
+ commitHistory();
9552
+ }
8729
9553
  canvas.style.cursor = "default";
8730
9554
  setCrosshairPoint(null);
8731
9555
  };
@@ -8766,6 +9590,7 @@ function createChart(element, options = {}) {
8766
9590
  canvas.focus({ preventScroll: true });
8767
9591
  }
8768
9592
  cancelKineticPan();
9593
+ cancelViewportTween();
8769
9594
  panVelocitySamples.length = 0;
8770
9595
  magnetModifierActive = event.metaKey || event.ctrlKey;
8771
9596
  shiftKeyActive = event.shiftKey;
@@ -8819,6 +9644,20 @@ function createChart(element, options = {}) {
8819
9644
  });
8820
9645
  return;
8821
9646
  }
9647
+ const markHit = getMarkAt(point.x, point.y);
9648
+ if (markHit && markClickHandler) {
9649
+ const rect = canvas.getBoundingClientRect();
9650
+ setCrosshairPoint(null);
9651
+ markClickHandler({
9652
+ kind: markHit.kind,
9653
+ mark: markHit.mark,
9654
+ x: markHit.x,
9655
+ y: markHit.y,
9656
+ clientX: rect.left + markHit.x,
9657
+ clientY: rect.top + markHit.y
9658
+ });
9659
+ return;
9660
+ }
8822
9661
  const orderRegion = getOrderActionRegion(point.x, point.y);
8823
9662
  if (orderRegion) {
8824
9663
  if (orderRegion.draggable) {
@@ -8857,6 +9696,28 @@ function createChart(element, options = {}) {
8857
9696
  setCrosshairPoint(null);
8858
9697
  return;
8859
9698
  }
9699
+ const alertHit = getAlertRegionAt(point.x, point.y);
9700
+ if (alertHit) {
9701
+ const target = alerts.find((alert) => alert.id === alertHit.id);
9702
+ if (target) {
9703
+ setCrosshairPoint(null);
9704
+ if (alertHit.part === "remove") {
9705
+ const removed = serializeAlert(target);
9706
+ removeAlert(target.id);
9707
+ alertActionHandler?.({ alert: removed, action: "remove" });
9708
+ return;
9709
+ }
9710
+ if (target.draggable) {
9711
+ activePointerId = event.pointerId;
9712
+ alertDragState = { id: target.id, startPrice: target.price, lastPrice: target.price, moved: false };
9713
+ capturePointer(event.pointerId);
9714
+ canvas.style.cursor = "ns-resize";
9715
+ return;
9716
+ }
9717
+ alertActionHandler?.({ alert: serializeAlert(target), action: "click", price: target.price });
9718
+ return;
9719
+ }
9720
+ }
8860
9721
  const paneButtonHit = getPaneButtonHit(point.x, point.y);
8861
9722
  if (paneButtonHit) {
8862
9723
  setCrosshairPoint(null);
@@ -9099,6 +9960,24 @@ function createChart(element, options = {}) {
9099
9960
  setCrosshairPoint(null);
9100
9961
  return;
9101
9962
  }
9963
+ if (alertDragState) {
9964
+ if (activePointerId !== null && event.pointerId !== activePointerId) {
9965
+ return;
9966
+ }
9967
+ const nextPrice = roundToPricePrecision(priceFromCanvasY(point.y));
9968
+ if (nextPrice !== alertDragState.lastPrice) {
9969
+ alertDragState.lastPrice = nextPrice;
9970
+ alertDragState.moved = true;
9971
+ const target = alerts.find((alert) => alert.id === alertDragState?.id);
9972
+ if (target) {
9973
+ target.price = nextPrice;
9974
+ target.lastPrice = data[data.length - 1]?.c ?? null;
9975
+ alertActionHandler?.({ alert: serializeAlert(target), action: "move", price: nextPrice, dragging: true });
9976
+ }
9977
+ scheduleDraw();
9978
+ }
9979
+ return;
9980
+ }
9102
9981
  if (orderDragState) {
9103
9982
  if (activePointerId !== null && event.pointerId !== activePointerId) {
9104
9983
  return;
@@ -9165,12 +10044,49 @@ function createChart(element, options = {}) {
9165
10044
  setCrosshairPoint(null);
9166
10045
  return;
9167
10046
  }
10047
+ const markHover = getMarkAt(point.x, point.y);
10048
+ const nextMarkKey = markHover ? `${markHover.kind}|${markHover.id}` : null;
10049
+ if (nextMarkKey !== hoveredMarkKey) {
10050
+ hoveredMarkKey = nextMarkKey;
10051
+ if (markHover) {
10052
+ const rect = canvas.getBoundingClientRect();
10053
+ markHoverHandler?.({
10054
+ kind: markHover.kind,
10055
+ mark: markHover.mark,
10056
+ x: markHover.x,
10057
+ y: markHover.y,
10058
+ clientX: rect.left + markHover.x,
10059
+ clientY: rect.top + markHover.y
10060
+ });
10061
+ } else {
10062
+ markHoverHandler?.(null);
10063
+ }
10064
+ scheduleDraw();
10065
+ }
10066
+ if (markHover) {
10067
+ canvas.title = markHover.mark.text ?? markHover.mark.label ?? "";
10068
+ canvas.style.cursor = "pointer";
10069
+ setCrosshairPoint(null);
10070
+ return;
10071
+ }
9168
10072
  const orderDragRegion = getOrderDragRegion(point.x, point.y);
9169
10073
  if (orderDragRegion) {
9170
10074
  canvas.style.cursor = "ns-resize";
9171
10075
  setCrosshairPoint(null);
9172
10076
  return;
9173
10077
  }
10078
+ const alertHover = getAlertRegionAt(point.x, point.y);
10079
+ const nextAlertId = alertHover?.id ?? null;
10080
+ if (nextAlertId !== hoveredAlertId) {
10081
+ hoveredAlertId = nextAlertId;
10082
+ scheduleDraw();
10083
+ }
10084
+ if (alertHover) {
10085
+ canvas.title = alertHover.part === "remove" ? "Remove alert" : "Drag to move alert";
10086
+ canvas.style.cursor = alertHover.part === "remove" ? "pointer" : "ns-resize";
10087
+ setCrosshairPoint(null);
10088
+ return;
10089
+ }
9174
10090
  const hoveredPane = getPaneAt(point.x, point.y);
9175
10091
  const nextHoveredPaneId = hoveredPane?.id ?? null;
9176
10092
  if (nextHoveredPaneId !== hoveredPaneId) {
@@ -9328,6 +10244,21 @@ function createChart(element, options = {}) {
9328
10244
  scheduleDraw();
9329
10245
  return;
9330
10246
  }
10247
+ if (alertDragState) {
10248
+ const target = alerts.find((alert) => alert.id === alertDragState?.id);
10249
+ if (target && alertDragState.moved) {
10250
+ alertActionHandler?.({
10251
+ alert: serializeAlert(target),
10252
+ action: "move",
10253
+ price: alertDragState.lastPrice,
10254
+ dragging: false
10255
+ });
10256
+ } else if (target) {
10257
+ alertActionHandler?.({ alert: serializeAlert(target), action: "click", price: target.price });
10258
+ }
10259
+ alertDragState = null;
10260
+ canvas.style.cursor = "default";
10261
+ }
9331
10262
  if (orderDragState) {
9332
10263
  const moved = orderDragState.lastPrice !== orderDragState.startPrice;
9333
10264
  const finalLine = orderLines.find((line) => line.id === orderDragState?.orderId);
@@ -9374,9 +10305,14 @@ function createChart(element, options = {}) {
9374
10305
  });
9375
10306
  paneDividerDrag = null;
9376
10307
  }
9377
- if (event?.type === "pointerleave" && (hoveredPaneId !== null || hoveredPaneButton !== null)) {
10308
+ if (event?.type === "pointerleave" && (hoveredPaneId !== null || hoveredPaneButton !== null || hoveredAlertId !== null || hoveredMarkKey !== null)) {
9378
10309
  hoveredPaneId = null;
9379
10310
  hoveredPaneButton = null;
10311
+ hoveredAlertId = null;
10312
+ if (hoveredMarkKey !== null) {
10313
+ hoveredMarkKey = null;
10314
+ markHoverHandler?.(null);
10315
+ }
9380
10316
  if (canvas.title) {
9381
10317
  canvas.title = "";
9382
10318
  }
@@ -9430,6 +10366,7 @@ function createChart(element, options = {}) {
9430
10366
  return;
9431
10367
  }
9432
10368
  cancelKineticPan();
10369
+ cancelViewportTween();
9433
10370
  const point = getCanvasPoint(event);
9434
10371
  const region = getHitRegion(point.x, point.y);
9435
10372
  if (region === "outside") {
@@ -9551,9 +10488,6 @@ function createChart(element, options = {}) {
9551
10488
  if (keyboard.enabled === false) {
9552
10489
  return;
9553
10490
  }
9554
- if (event.altKey || event.ctrlKey || event.metaKey) {
9555
- return;
9556
- }
9557
10491
  const panStep = Math.max(1, keyboard.panBars ?? 1) * (event.shiftKey ? 10 : 1);
9558
10492
  const emit = (action, drawing) => {
9559
10493
  keyboardShortcutHandler?.({
@@ -9563,6 +10497,21 @@ function createChart(element, options = {}) {
9563
10497
  ...drawing ? { drawing } : {}
9564
10498
  });
9565
10499
  };
10500
+ if ((event.ctrlKey || event.metaKey) && !event.altKey) {
10501
+ const key = event.key.toLowerCase();
10502
+ if (keyboard.undoRedo !== false && (key === "z" || key === "y")) {
10503
+ const wantsRedo = key === "y" || event.shiftKey;
10504
+ const applied = wantsRedo ? redo() : undo();
10505
+ if (applied) {
10506
+ event.preventDefault();
10507
+ emit(wantsRedo ? "redo" : "undo");
10508
+ }
10509
+ return;
10510
+ }
10511
+ }
10512
+ if (event.altKey || event.ctrlKey || event.metaKey) {
10513
+ return;
10514
+ }
9566
10515
  switch (event.key) {
9567
10516
  case "Delete":
9568
10517
  case "Backspace": {
@@ -9707,6 +10656,21 @@ function createChart(element, options = {}) {
9707
10656
  resetRightMarginCache();
9708
10657
  doubleClickEnabled = mergedOptions.doubleClickEnabled;
9709
10658
  doubleClickAction = mergedOptions.doubleClickAction;
10659
+ setIndicatorLiveThrottleMs(mergedOptions.indicatorUpdate?.liveThrottleMs ?? 80);
10660
+ if (nextOptions.timezone !== void 0) {
10661
+ timezoneSetting = nextOptions.timezone || "local";
10662
+ zoneClock = createZoneClock(timezoneSetting);
10663
+ }
10664
+ if (nextOptions.timeFormat !== void 0) {
10665
+ timeFormat = nextOptions.timeFormat === "12h" ? "12h" : "24h";
10666
+ }
10667
+ if (nextOptions.session !== void 0) {
10668
+ sessionOptions = { ...sessionOptions, ...nextOptions.session ?? {} };
10669
+ compiledSession = compileSession(sessionOptions.spec);
10670
+ }
10671
+ if (nextOptions.alerts !== void 0) {
10672
+ setAlerts(nextOptions.alerts ?? []);
10673
+ }
9710
10674
  applyAccessibilityAttributes();
9711
10675
  const isTickerSmoothingEnabled = mergedOptions.tickerLine?.smoothing ?? false;
9712
10676
  if (!isTickerSmoothingEnabled) {
@@ -9767,7 +10731,7 @@ function createChart(element, options = {}) {
9767
10731
  resetRightMarginCache();
9768
10732
  scheduleDraw();
9769
10733
  };
9770
- const setData = (nextData) => {
10734
+ const setDataInternal = (nextData) => {
9771
10735
  const hadData = data.length > 0;
9772
10736
  const previousCount = data.length;
9773
10737
  const previousCenterRounded = hadData ? Math.round(xCenter) : 0;
@@ -9809,9 +10773,10 @@ function createChart(element, options = {}) {
9809
10773
  if (lastPoint) {
9810
10774
  pushSmoothedTicker(lastPoint.c, lastPoint.v);
9811
10775
  }
10776
+ evaluateAlertsFromLatestBar();
9812
10777
  scheduleDraw();
9813
10778
  };
9814
- const upsertBar = (point) => {
10779
+ const upsertBarInternal = (point) => {
9815
10780
  const time = new Date(point.t);
9816
10781
  const timeMs = time.getTime();
9817
10782
  const open = Number(point.o);
@@ -9832,7 +10797,7 @@ function createChart(element, options = {}) {
9832
10797
  };
9833
10798
  const last = data[data.length - 1];
9834
10799
  if (!last) {
9835
- setData([point]);
10800
+ setDataInternal([point]);
9836
10801
  return;
9837
10802
  }
9838
10803
  const lastMs = last.time.getTime();
@@ -9854,8 +10819,163 @@ function createChart(element, options = {}) {
9854
10819
  reanchorDrawingsToData();
9855
10820
  }
9856
10821
  pushSmoothedTicker(parsed.c, parsed.v);
10822
+ evaluateAlerts(parsed.c, timeMs, data.length - 1);
9857
10823
  scheduleDraw();
9858
10824
  };
10825
+ const toRawPoint = (point) => ({
10826
+ t: point.time.toISOString(),
10827
+ o: point.o,
10828
+ h: point.h,
10829
+ l: point.l,
10830
+ c: point.c,
10831
+ ...point.v === void 0 ? {} : { v: point.v }
10832
+ });
10833
+ const getReplayState = () => {
10834
+ if (!replay) {
10835
+ return {
10836
+ active: false,
10837
+ playing: false,
10838
+ speed: 1,
10839
+ index: Math.max(0, data.length - 1),
10840
+ total: data.length,
10841
+ atEnd: true,
10842
+ timeMs: data.length > 0 ? data[data.length - 1].time.getTime() : null
10843
+ };
10844
+ }
10845
+ const total = replay.fullRaw.length;
10846
+ const revealed = replay.fullRaw[replay.cursor];
10847
+ const revealedMs = revealed ? Date.parse(String(revealed.t)) : Number.NaN;
10848
+ return {
10849
+ active: true,
10850
+ playing: replay.playing,
10851
+ speed: replay.speed,
10852
+ index: replay.cursor,
10853
+ total,
10854
+ atEnd: replay.cursor >= total - 1,
10855
+ timeMs: Number.isFinite(revealedMs) ? revealedMs : null
10856
+ };
10857
+ };
10858
+ const emitReplayState = () => {
10859
+ replayStateHandler?.(getReplayState());
10860
+ };
10861
+ const applyReplaySlice = () => {
10862
+ if (!replay) return;
10863
+ setDataInternal(replay.fullRaw.slice(0, replay.cursor + 1));
10864
+ };
10865
+ const clearReplayTimer = () => {
10866
+ if (replay?.timerId) {
10867
+ clearTimeout(replay.timerId);
10868
+ replay.timerId = null;
10869
+ }
10870
+ };
10871
+ const scheduleReplayTick = () => {
10872
+ if (!replay || !replay.playing) return;
10873
+ clearReplayTimer();
10874
+ const intervalMs = Math.max(16, 1e3 / Math.max(0.05, replay.speed));
10875
+ replay.timerId = setTimeout(() => {
10876
+ if (!replay || !replay.playing) return;
10877
+ if (replay.cursor >= replay.fullRaw.length - 1) {
10878
+ replay.playing = false;
10879
+ clearReplayTimer();
10880
+ emitReplayState();
10881
+ return;
10882
+ }
10883
+ replay.cursor += 1;
10884
+ applyReplaySlice();
10885
+ emitReplayState();
10886
+ scheduleReplayTick();
10887
+ }, intervalMs);
10888
+ };
10889
+ const startReplay = (startOptions = {}) => {
10890
+ const source = replay ? replay.fullRaw : data.map((point) => toRawPoint(point));
10891
+ if (source.length === 0) return;
10892
+ clearReplayTimer();
10893
+ let cursor;
10894
+ if (Number.isFinite(startOptions.fromTimeMs)) {
10895
+ const target = Number(startOptions.fromTimeMs);
10896
+ const resolved = findNearestIndexForTimeMs(target);
10897
+ cursor = resolved ?? Math.floor(source.length * 0.7);
10898
+ } else if (Number.isFinite(startOptions.fromIndex)) {
10899
+ cursor = Math.floor(Number(startOptions.fromIndex));
10900
+ } else {
10901
+ cursor = Math.floor(source.length * 0.7);
10902
+ }
10903
+ cursor = clamp(cursor, 0, source.length - 1);
10904
+ const speed = Math.max(0.05, Number(startOptions.speed) || replay?.speed || 1);
10905
+ replay = { fullRaw: source, cursor, playing: false, speed, timerId: null };
10906
+ applyReplaySlice();
10907
+ setFollowingLatest(true);
10908
+ if (startOptions.play) {
10909
+ replay.playing = true;
10910
+ scheduleReplayTick();
10911
+ }
10912
+ emitReplayState();
10913
+ };
10914
+ const stopReplay = () => {
10915
+ if (!replay) return;
10916
+ clearReplayTimer();
10917
+ const full = replay.fullRaw;
10918
+ replay = null;
10919
+ setDataInternal(full);
10920
+ setFollowingLatest(true);
10921
+ emitReplayState();
10922
+ };
10923
+ const replayStep = (bars = 1) => {
10924
+ if (!replay) return;
10925
+ const step = Math.trunc(bars) || 1;
10926
+ const next = clamp(replay.cursor + step, 0, replay.fullRaw.length - 1);
10927
+ if (next === replay.cursor) return;
10928
+ replay.cursor = next;
10929
+ applyReplaySlice();
10930
+ emitReplayState();
10931
+ };
10932
+ const replayPlay = (speed) => {
10933
+ if (!replay) return;
10934
+ if (Number.isFinite(speed)) replay.speed = Math.max(0.05, Number(speed));
10935
+ if (replay.cursor >= replay.fullRaw.length - 1) return;
10936
+ replay.playing = true;
10937
+ scheduleReplayTick();
10938
+ emitReplayState();
10939
+ };
10940
+ const replayPause = () => {
10941
+ if (!replay || !replay.playing) return;
10942
+ replay.playing = false;
10943
+ clearReplayTimer();
10944
+ emitReplayState();
10945
+ };
10946
+ const setReplaySpeed = (speed) => {
10947
+ if (!replay || !Number.isFinite(speed)) return;
10948
+ replay.speed = clamp(Number(speed), 0.05, 100);
10949
+ if (replay.playing) scheduleReplayTick();
10950
+ emitReplayState();
10951
+ };
10952
+ const onReplayStateChange = (handler) => {
10953
+ replayStateHandler = handler;
10954
+ };
10955
+ const setData = (nextData) => {
10956
+ if (replay) {
10957
+ replay.fullRaw = nextData.slice();
10958
+ replay.cursor = clamp(replay.cursor, 0, Math.max(0, replay.fullRaw.length - 1));
10959
+ applyReplaySlice();
10960
+ emitReplayState();
10961
+ return;
10962
+ }
10963
+ setDataInternal(nextData);
10964
+ };
10965
+ const upsertBar = (point) => {
10966
+ if (replay) {
10967
+ const timeMs = Date.parse(String(point.t));
10968
+ if (!Number.isFinite(timeMs)) return;
10969
+ const buffer = replay.fullRaw;
10970
+ const lastRaw = buffer[buffer.length - 1];
10971
+ const lastMs = lastRaw ? Date.parse(String(lastRaw.t)) : Number.NaN;
10972
+ if (Number.isFinite(lastMs) && timeMs === lastMs) buffer[buffer.length - 1] = point;
10973
+ else if (!Number.isFinite(lastMs) || timeMs > lastMs) buffer.push(point);
10974
+ emitReplayState();
10975
+ return;
10976
+ }
10977
+ upsertBarInternal(point);
10978
+ };
9859
10979
  const setPriceLines = (lines) => {
9860
10980
  priceLines = lines.map((line, index) => ({
9861
10981
  ...line,
@@ -10044,6 +11164,96 @@ function createChart(element, options = {}) {
10044
11164
  tradeMarkers = Array.isArray(markers) ? markers.slice() : [];
10045
11165
  scheduleDraw();
10046
11166
  };
11167
+ const setBarMarks = (marks) => {
11168
+ barMarks = Array.isArray(marks) ? marks.slice() : [];
11169
+ scheduleDraw();
11170
+ };
11171
+ const getBarMarks = () => barMarks.slice();
11172
+ const setTimescaleMarks = (marks) => {
11173
+ timescaleMarks = Array.isArray(marks) ? marks.slice() : [];
11174
+ scheduleDraw();
11175
+ };
11176
+ const getTimescaleMarks = () => timescaleMarks.slice();
11177
+ const onMarkClick = (handler) => {
11178
+ markClickHandler = handler;
11179
+ };
11180
+ const onMarkHover = (handler) => {
11181
+ markHoverHandler = handler;
11182
+ };
11183
+ const setAlerts = (nextAlerts) => {
11184
+ alerts = (Array.isArray(nextAlerts) ? nextAlerts : []).map((alert) => normalizeAlert(alert));
11185
+ const last = data[data.length - 1];
11186
+ if (last) {
11187
+ for (const alert of alerts) alert.lastPrice = last.c;
11188
+ }
11189
+ resetRightMarginCache();
11190
+ scheduleDraw();
11191
+ };
11192
+ const addAlert = (alert) => {
11193
+ const next = normalizeAlert(alert);
11194
+ const last = data[data.length - 1];
11195
+ if (last) next.lastPrice = last.c;
11196
+ alerts.push(next);
11197
+ resetRightMarginCache();
11198
+ scheduleDraw();
11199
+ return next.id;
11200
+ };
11201
+ const updateAlert = (id, patch) => {
11202
+ const target = alerts.find((alert) => alert.id === id);
11203
+ if (!target) return;
11204
+ if (Number.isFinite(patch.price)) target.price = Number(patch.price);
11205
+ if (patch.condition) target.condition = patch.condition;
11206
+ if (patch.label !== void 0) target.label = patch.label;
11207
+ if (patch.color !== void 0) target.color = patch.color;
11208
+ if (patch.active !== void 0) target.active = patch.active;
11209
+ if (patch.once !== void 0) target.once = patch.once;
11210
+ if (patch.draggable !== void 0) target.draggable = patch.draggable;
11211
+ if (patch.triggered !== void 0) {
11212
+ target.triggered = patch.triggered;
11213
+ if (!patch.triggered) target.lastPrice = data[data.length - 1]?.c ?? null;
11214
+ }
11215
+ resetRightMarginCache();
11216
+ scheduleDraw();
11217
+ };
11218
+ const removeAlert = (id) => {
11219
+ alerts = alerts.filter((alert) => alert.id !== id);
11220
+ if (hoveredAlertId === id) hoveredAlertId = null;
11221
+ resetRightMarginCache();
11222
+ scheduleDraw();
11223
+ };
11224
+ const getAlerts = () => alerts.map((alert) => serializeAlert(alert));
11225
+ const onAlertTrigger = (handler) => {
11226
+ alertTriggerHandler = handler;
11227
+ };
11228
+ const onAlertAction = (handler) => {
11229
+ alertActionHandler = handler;
11230
+ };
11231
+ const setTimezone = (timezone) => {
11232
+ timezoneSetting = timezone || "local";
11233
+ zoneClock = createZoneClock(timezoneSetting);
11234
+ scheduleDraw();
11235
+ };
11236
+ const getTimezone = () => timezoneSetting;
11237
+ const setTimeFormat = (format) => {
11238
+ timeFormat = format === "12h" ? "12h" : "24h";
11239
+ resetRightMarginCache();
11240
+ scheduleDraw();
11241
+ };
11242
+ const getTimeFormat = () => timeFormat;
11243
+ const setSession = (session) => {
11244
+ if (session === null) {
11245
+ sessionOptions = { ...sessionOptions, spec: null };
11246
+ } else if (typeof session === "string") {
11247
+ sessionOptions = { ...sessionOptions, spec: session };
11248
+ } else if ("segments" in session || "timezone" in session || "days" in session) {
11249
+ sessionOptions = { ...sessionOptions, spec: session };
11250
+ } else {
11251
+ sessionOptions = { ...sessionOptions, ...session };
11252
+ }
11253
+ compiledSession = compileSession(sessionOptions.spec);
11254
+ scheduleDraw();
11255
+ };
11256
+ const getSession = () => ({ ...sessionOptions });
10047
11257
  const setMagnetMode = (mode) => {
10048
11258
  magnetMode = mode;
10049
11259
  };
@@ -10063,7 +11273,13 @@ function createChart(element, options = {}) {
10063
11273
  drawings = dedupeDrawingIds(nextDrawings.map((drawing) => normalizeDrawingState(drawing)));
10064
11274
  draftDrawing = null;
10065
11275
  reanchorDrawingsToData();
10066
- emitDrawingsChange();
11276
+ historySuspended += 1;
11277
+ try {
11278
+ emitDrawingsChange();
11279
+ } finally {
11280
+ historySuspended -= 1;
11281
+ }
11282
+ resetHistoryBaseline();
10067
11283
  scheduleDraw();
10068
11284
  };
10069
11285
  const addDrawing = (drawing) => {
@@ -10148,7 +11364,10 @@ function createChart(element, options = {}) {
10148
11364
  indicators: getIndicators(),
10149
11365
  magnetMode: getMagnetMode(),
10150
11366
  priceScale: getPriceScale(),
10151
- drawingDefaults
11367
+ drawingDefaults,
11368
+ alerts: getAlerts(),
11369
+ timezone: getTimezone(),
11370
+ timeFormat: getTimeFormat()
10152
11371
  };
10153
11372
  };
10154
11373
  const loadState = (state) => {
@@ -10177,15 +11396,28 @@ function createChart(element, options = {}) {
10177
11396
  }
10178
11397
  }
10179
11398
  }
11399
+ if (Array.isArray(state.alerts)) {
11400
+ setAlerts(state.alerts);
11401
+ }
11402
+ if (typeof state.timezone === "string") {
11403
+ setTimezone(state.timezone);
11404
+ }
11405
+ if (state.timeFormat === "12h" || state.timeFormat === "24h") {
11406
+ setTimeFormat(state.timeFormat);
11407
+ }
10180
11408
  if (state.viewport && typeof state.viewport === "object") {
10181
11409
  setViewport(state.viewport);
10182
11410
  }
11411
+ clearHistory();
10183
11412
  };
10184
11413
  const takeScreenshot = (options2 = {}) => {
10185
11414
  return canvas.toDataURL(options2.type ?? "image/png", options2.quality);
10186
11415
  };
10187
11416
  const destroy = () => {
11417
+ cancelViewportTween();
10188
11418
  cancelLongPressTimer();
11419
+ clearReplayTimer();
11420
+ replay = null;
10189
11421
  datafeedUnsubscribe?.();
10190
11422
  datafeedUnsubscribe = null;
10191
11423
  datafeed = null;
@@ -10219,6 +11451,10 @@ function createChart(element, options = {}) {
10219
11451
  }
10220
11452
  element.innerHTML = "";
10221
11453
  };
11454
+ if (Array.isArray(options.alerts) && options.alerts.length > 0) {
11455
+ setAlerts(options.alerts);
11456
+ }
11457
+ resetHistoryBaseline();
10222
11458
  draw();
10223
11459
  armCountdownHeartbeat();
10224
11460
  return {
@@ -10302,6 +11538,42 @@ function createChart(element, options = {}) {
10302
11538
  saveState,
10303
11539
  loadState,
10304
11540
  takeScreenshot,
11541
+ setTimezone,
11542
+ getTimezone,
11543
+ setTimeFormat,
11544
+ getTimeFormat,
11545
+ setSession,
11546
+ getSession,
11547
+ undo,
11548
+ redo,
11549
+ canUndo: () => historyPast.length > 0,
11550
+ canRedo: () => historyFuture.length > 0,
11551
+ getUndoRedoState,
11552
+ onUndoRedoStateChange: (handler) => {
11553
+ undoRedoStateHandler = handler;
11554
+ },
11555
+ clearHistory,
11556
+ startReplay,
11557
+ stopReplay,
11558
+ replayStep,
11559
+ replayPlay,
11560
+ replayPause,
11561
+ setReplaySpeed,
11562
+ getReplayState,
11563
+ onReplayStateChange,
11564
+ setAlerts,
11565
+ addAlert,
11566
+ updateAlert,
11567
+ removeAlert,
11568
+ getAlerts,
11569
+ onAlertTrigger,
11570
+ onAlertAction,
11571
+ setBarMarks,
11572
+ getBarMarks,
11573
+ setTimescaleMarks,
11574
+ getTimescaleMarks,
11575
+ onMarkClick,
11576
+ onMarkHover,
10305
11577
  resize,
10306
11578
  destroy
10307
11579
  };
@@ -10312,8 +11584,10 @@ export {
10312
11584
  FIB_DEFAULT_PALETTE,
10313
11585
  POSITION_DEFAULT_COLORS,
10314
11586
  SCRIPT_SOURCE_INPUT_VALUES,
11587
+ SESSION_PRESETS,
10315
11588
  compileScriptIndicator,
10316
11589
  createChart,
10317
11590
  extractScriptInputs,
11591
+ isValidTimeZone,
10318
11592
  validateScriptSource
10319
11593
  };