wickchart-sessions 0.1.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.
Files changed (4) hide show
  1. package/README.md +62 -0
  2. package/core.mjs +233 -0
  3. package/package.json +38 -0
  4. package/sessions.mjs +253 -0
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # wickchart-sessions
2
+
3
+ Market session shading for [wickchart](https://github.com/benyblack/wickchart),
4
+ as an opt-in plugin layer — the core stays session-free. Zero dependencies.
5
+
6
+ Translucent session bands (Asia / London / New York, or your own definitions)
7
+ under the price action, optional closed-weekend shading, and labels at the top
8
+ of each band. Sessions are plain config: presets for crypto & forex use the
9
+ common UTC convention; equity/futures presets use IANA timezones, so 09:30 is
10
+ the real 09:30 across DST changes. Bands ride zoom & pan and hover events tell
11
+ you which session the crosshair is in.
12
+
13
+ ```js
14
+ npm install wickchart wickchart-sessions // the plugin is a separate package
15
+
16
+ import 'wickchart'; // the chart itself
17
+ import { attachSessions } from 'wickchart-sessions';
18
+
19
+ const chart = document.querySelector('wick-chart');
20
+ const sessions = attachSessions(chart, { preset: 'crypto' });
21
+
22
+ sessions.setPreset('nyse'); // 'crypto' | 'forex' | 'nyse' | 'cme' | null
23
+ sessions.setSessions([...]); // custom defs — replaces the preset
24
+ sessions.setWeekends(true); // shade closed Sat+Sun (default for nyse/cme)
25
+ sessions.setLabels(false); // band labels at the top (default on)
26
+ sessions.setOpacity(0.12); // band fill alpha (default 0.08)
27
+ sessions.detach();
28
+
29
+ // which session is the crosshair in?
30
+ chart.addEventListener('wick:sessions', (e) => status.textContent = e.detail.hover || '');
31
+ ```
32
+
33
+ Custom defs — `{ name, start, end, tz?, days?, color?, alpha? }`:
34
+
35
+ ```js
36
+ sessions.setSessions([
37
+ { name: 'NY RTH', start: '09:30', end: '16:00', tz: 'America/New_York',
38
+ days: [1, 2, 3, 4, 5], color: '#4c8dff' }, // DST-exact, weekdays
39
+ { name: 'Overnight', start: '18:00', end: '06:00', utcOffset: 0 }, // crosses midnight
40
+ ]);
41
+ ```
42
+
43
+ - `end <= start` crosses midnight into the next day. `end: '24:00'` (or
44
+ `'00:00'`) ends at midnight.
45
+ - `tz` is any IANA name (DST-aware); `utcOffset` is fixed minutes east of
46
+ UTC; neither means UTC.
47
+ - `days` filters on the band's start weekday in the session timezone
48
+ (0 = Sunday).
49
+ - `color` is a hex, or `up` / `down` / `accent` for theme colors; uncolored
50
+ sessions cycle through four default tints.
51
+ - Defs are normalized and validated — invalid entries are dropped, never
52
+ thrown. `getSessions()` returns the active, JSON-serializable list.
53
+
54
+ Presets: **crypto** (Asia 00–08, London 07–16, New York 12–21 UTC), **forex**
55
+ (+ Sydney 21–06, Tokyo 00–09 UTC), **nyse** (09:30–16:00 America/New_York,
56
+ weekdays, weekends shaded), **cme** (08:30–15:00 America/Chicago, weekdays,
57
+ weekends shaded).
58
+
59
+ The hover bridge listens to the chart's own crosshair events, so shading never
60
+ claims a pointer gesture — pan/zoom/measure work untouched.
61
+
62
+ Peer dependency: wickchart ≥ 1.4.0 (the plugin layer API).
package/core.mjs ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * wickchart-sessions — pure session model: presets, normalization, timezone
3
+ * math and band computation. No DOM, no canvas; mirrors the host library's
4
+ * core discipline. Everything here is unit-testable plain data in / data out.
5
+ */
6
+
7
+ export const PRESETS = {
8
+ // crypto runs 24/7; session times are the common UTC convention
9
+ crypto: [
10
+ { name: 'Asia', start: '00:00', end: '08:00' },
11
+ { name: 'London', start: '07:00', end: '16:00' },
12
+ { name: 'New York', start: '12:00', end: '21:00' },
13
+ ],
14
+ forex: [
15
+ { name: 'Sydney', start: '21:00', end: '06:00' },
16
+ { name: 'Tokyo', start: '00:00', end: '09:00' },
17
+ { name: 'London', start: '07:00', end: '16:00' },
18
+ { name: 'New York', start: '12:00', end: '21:00' },
19
+ ],
20
+ // equities/futures: DST-exact wall-clock times, weekdays only
21
+ nyse: [{ name: 'NYSE', start: '09:30', end: '16:00', tz: 'America/New_York', days: [1, 2, 3, 4, 5] }],
22
+ cme: [{ name: 'RTH', start: '08:30', end: '15:00', tz: 'America/Chicago', days: [1, 2, 3, 4, 5] }],
23
+ };
24
+
25
+ /** Presets that default to weekend shading (the market is actually closed). */
26
+ export const WEEKEND_PRESETS = new Set(['nyse', 'cme']);
27
+
28
+ /** Fill tints cycled per session — picked to read on both dark and light themes. */
29
+ export const DEFAULT_COLORS = ['#4c8dff', '#f0b90b', '#16c784', '#a78bfa'];
30
+
31
+ export const WEEKEND_COLOR = '#8b949e';
32
+
33
+ const MAX_SESSIONS = 12;
34
+ const MAX_NAME = 24;
35
+ const DAY = 86_400_000;
36
+ /** Session shading stops making visual sense far out; cap the day scan. */
37
+ export const MAX_DAYS = 750;
38
+
39
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
40
+ const HM = /^([01]?\d|2[0-4]):([0-5]\d)$/;
41
+
42
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
43
+
44
+ /** '09:30' → 570 minutes since midnight; '24:00' → 1440; else NaN. */
45
+ export function parseHM(str) {
46
+ const m = typeof str === 'string' && HM.exec(str.trim());
47
+ if (!m) return NaN;
48
+ const h = Number(m[1]);
49
+ if (h === 24 && Number(m[2]) !== 0) return NaN; // '24:00' is midnight, '24:01' is nothing
50
+ return h * 60 + Number(m[2]);
51
+ }
52
+
53
+ function validTz(tz) {
54
+ if (typeof tz !== 'string' || !tz || tz.length > 40) return false;
55
+ try {
56
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
57
+ return true;
58
+ } catch (_) {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Validate + clamp a list of raw session defs into plain, serializable data.
65
+ * Invalid entries are dropped (never throw) — same contract as
66
+ * normalizeDrawings / normalizeOverlays in the host core.
67
+ *
68
+ * Session shape: { name, start: 'HH:MM', end: 'HH:MM', tz?: IANA name,
69
+ * utcOffset?: minutes east of UTC, days?: [0..6] (0=Sun, in session tz),
70
+ * color?: '#hex'|'up'|'down'|'accent', alpha?: 0..1 }.
71
+ * `end <= start` crosses midnight into the next day; neither tz nor
72
+ * utcOffset means UTC.
73
+ */
74
+ export function normalizeSessions(list) {
75
+ if (!Array.isArray(list)) return [];
76
+ const out = [];
77
+ for (const raw of list) {
78
+ if (!raw || typeof raw !== 'object') continue;
79
+ if (out.length >= MAX_SESSIONS) break;
80
+ const name = typeof raw.name === 'string' ? raw.name.trim().slice(0, MAX_NAME) : '';
81
+ if (!name) continue;
82
+ // accepts raw defs ('09:30' strings) AND already-normalized defs (minute
83
+ // numbers), so the output of getSessions() is a valid setSessions() input
84
+ const startMin = isNum(raw.startMin) ? Math.round(raw.startMin) : parseHM(raw.start);
85
+ let endMin = isNum(raw.endMin) ? Math.round(raw.endMin) : parseHM(raw.end);
86
+ if (!isNum(startMin) || !isNum(endMin) || startMin < 0 || startMin > 1439 || endMin < 0 || endMin > 1440) continue;
87
+ if (endMin === 0) endMin = 1440; // end '00:00' = midnight, same as '24:00'
88
+ const tz = validTz(raw.tz) ? raw.tz : null;
89
+ const utcOffset = isNum(raw.utcOffset) ? Math.max(-720, Math.min(840, Math.round(raw.utcOffset))) : null;
90
+ let days = null;
91
+ if (Array.isArray(raw.days)) {
92
+ days = [...new Set(raw.days.filter((d) => Number.isInteger(d) && d >= 0 && d <= 6))].sort();
93
+ if (!days.length) days = null;
94
+ }
95
+ const color = typeof raw.color === 'string' && (['up', 'down', 'accent'].includes(raw.color) || HEX.test(raw.color))
96
+ ? raw.color
97
+ : null;
98
+ const alpha = isNum(raw.alpha) ? Math.max(0, Math.min(1, raw.alpha)) : null;
99
+ out.push({ name, startMin, endMin, tz, utcOffset, days, color, alpha });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /* ------------------------ timezone math ------------------------ */
105
+
106
+ const dtfCache = new Map();
107
+
108
+ function dtfFor(tz) {
109
+ let f = dtfCache.get(tz);
110
+ if (!f) {
111
+ f = new Intl.DateTimeFormat('en-US', {
112
+ timeZone: tz,
113
+ hourCycle: 'h23',
114
+ year: 'numeric',
115
+ month: '2-digit',
116
+ day: '2-digit',
117
+ hour: '2-digit',
118
+ minute: '2-digit',
119
+ });
120
+ dtfCache.set(tz, f);
121
+ }
122
+ return f;
123
+ }
124
+
125
+ /** Wall-clock parts {y, mo, d, hh, mm} of an epoch-ms instant in a tz. */
126
+ function wallParts(at, tz) {
127
+ const parts = dtfFor(tz).formatToParts(new Date(at));
128
+ const get = (type) => Number(parts.find((p) => p.type === type).value);
129
+ return { y: get('year'), mo: get('month'), d: get('day'), hh: get('hour'), mm: get('minute') };
130
+ }
131
+
132
+ /** Offset east of UTC in minutes at instant `at` (DST-aware via Intl). */
133
+ function tzOffsetMin(at, tz) {
134
+ const p = wallParts(at, tz);
135
+ return Math.round((Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mm) - at) / 60000);
136
+ }
137
+
138
+ const zonedCache = new Map();
139
+
140
+ /**
141
+ * Epoch ms of "wall time `minutes` past midnight of UTC-date (y, mo, d) in
142
+ * tz". Guess-and-correct handles DST; per-(tz, date, minute) memoization
143
+ * keeps panning cheap (offsets repeat across days, Intl is the slow part).
144
+ */
145
+ export function zonedToUtc(y, mo, d, minutes, tz) {
146
+ if (!tz) return Date.UTC(y, mo - 1, d, 0, minutes); // UTC (no Intl needed)
147
+ const key = tz + '|' + y + '-' + mo + '-' + d + '|' + minutes;
148
+ const hit = zonedCache.get(key);
149
+ if (hit != null) return hit;
150
+ const wall = Date.UTC(y, mo - 1, d, 0, minutes);
151
+ let g = wall;
152
+ for (let i = 0; i < 2; i++) {
153
+ g = wall - tzOffsetMin(g, tz) * 60000;
154
+ }
155
+ if (zonedCache.size > 8192) zonedCache.clear();
156
+ zonedCache.set(key, g);
157
+ return g;
158
+ }
159
+
160
+ /** 0=Sun..6=Sat of an instant's wall-clock date in a tz (UTC when no tz). */
161
+ export function weekdayInTz(at, tz) {
162
+ if (!tz) return new Date(at).getUTCDay();
163
+ const p = wallParts(at, tz);
164
+ return new Date(Date.UTC(p.y, p.mo - 1, p.d)).getUTCDay();
165
+ }
166
+
167
+ /** utcOffset (minutes) form → same shape as tz form, minus the Intl work. */
168
+ function fixedOffsetToUtc(y, mo, d, minutes, off) {
169
+ return Date.UTC(y, mo - 1, d, 0, minutes) - off * 60000;
170
+ }
171
+
172
+ /** Resolve a def's epoch start/end on one calendar date (UTC-anchored). */
173
+ function defBoundsOn(def, y, mo, d) {
174
+ if (def.utcOffset != null) {
175
+ const endMin = def.endMin > def.startMin ? def.endMin : def.endMin + 1440;
176
+ return {
177
+ s: fixedOffsetToUtc(y, mo, d, def.startMin, def.utcOffset),
178
+ e: fixedOffsetToUtc(y, mo, d, endMin, def.utcOffset),
179
+ weekday: new Date(fixedOffsetToUtc(y, mo, d, 0, def.utcOffset)).getUTCDay(),
180
+ };
181
+ }
182
+ const s = zonedToUtc(y, mo, d, def.startMin, def.tz);
183
+ const e = zonedToUtc(y, mo, d, def.endMin > def.startMin ? def.endMin : def.endMin + 1440, def.tz);
184
+ return { s, e, weekday: weekdayInTz(s, def.tz) };
185
+ }
186
+
187
+ /**
188
+ * Session bands overlapping the window [t0, t1] (epoch ms): one entry per
189
+ * (def, day), clipped to the window. `end <= start` defs roll into the next
190
+ * day; `days` filters on the band's start weekday in the session timezone.
191
+ */
192
+ export function bandsFor(defs, t0, t1) {
193
+ if (!Array.isArray(defs) || !defs.length || !isNum(t0) || !isNum(t1) || t1 <= t0) return [];
194
+ const out = [];
195
+ const startDay = Math.floor(t0 / DAY) - 2;
196
+ const endDay = Math.floor(t1 / DAY) + 2;
197
+ for (let day = startDay; day <= endDay && day - startDay < MAX_DAYS; day++) {
198
+ const base = new Date(day * DAY);
199
+ const y = base.getUTCFullYear();
200
+ const mo = base.getUTCMonth() + 1;
201
+ const d = base.getUTCDate();
202
+ for (const def of defs) {
203
+ const b = defBoundsOn(def, y, mo, d);
204
+ if (b.e <= b.s) continue;
205
+ if (def.days && !def.days.includes(b.weekday)) continue;
206
+ if (b.e > t0 && b.s < t1) out.push({ def, start: Math.max(b.s, t0), end: Math.min(b.e, t1) });
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * Weekend shading bands (Sat + Sun midnight-to-midnight in `tz`, merged
214
+ * into one band each): epoch ms entries clipped to [t0, t1].
215
+ */
216
+ export function weekendBands(t0, t1, tz) {
217
+ if (!isNum(t0) || !isNum(t1) || t1 <= t0) return [];
218
+ const out = [];
219
+ const startDay = Math.floor(t0 / DAY) - 2;
220
+ const endDay = Math.floor(t1 / DAY) + 2;
221
+ for (let day = startDay; day <= endDay && day - startDay < MAX_DAYS; day++) {
222
+ const base = new Date(day * DAY);
223
+ const y = base.getUTCFullYear();
224
+ const mo = base.getUTCMonth() + 1;
225
+ const d = base.getUTCDate();
226
+ const zoned = tz ? zonedToUtc(y, mo, d, 0, tz) : day * DAY;
227
+ if (weekdayInTz(zoned, tz) !== 6) continue; // Saturdays only — each carries its own Sunday
228
+ const s = zoned;
229
+ const e = tz ? zonedToUtc(y, mo, d + 2, 0, tz) : s + 2 * DAY;
230
+ if (e > t0 && s < t1) out.push({ start: Math.max(s, t0), end: Math.min(e, t1) });
231
+ }
232
+ return out;
233
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "wickchart-sessions",
3
+ "version": "0.1.0",
4
+ "description": "Market session shading for wickchart — Asia/London/New York and custom sessions with DST-exact IANA timezones, weekend shading, crosshair hover events. Opt-in plugin, zero dependencies.",
5
+ "type": "module",
6
+ "main": "sessions.mjs",
7
+ "module": "sessions.mjs",
8
+ "exports": {
9
+ ".": "./sessions.mjs",
10
+ "./core": "./core.mjs"
11
+ },
12
+ "files": [
13
+ "sessions.mjs",
14
+ "core.mjs",
15
+ "README.md"
16
+ ],
17
+ "keywords": [
18
+ "chart",
19
+ "charting",
20
+ "sessions",
21
+ "trading",
22
+ "web-component",
23
+ "wickchart",
24
+ "canvas",
25
+ "crypto",
26
+ "forex",
27
+ "timezone"
28
+ ],
29
+ "license": "MIT",
30
+ "peerDependencies": {
31
+ "wickchart": ">=1.4.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "wickchart": {
35
+ "optional": true
36
+ }
37
+ }
38
+ }
package/sessions.mjs ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * wickchart-sessions — session shading as a wickchart plugin layer: Asia /
3
+ * London / New York and other market sessions drawn as translucent bands
4
+ * behind nothing but time, plus optional weekend shading for closed-market
5
+ * instruments. Sessions are plain config (preset or custom defs with IANA
6
+ * timezones — DST-exact), so they ride zoom & pan and serialize to JSON.
7
+ * The whole plugin builds on the public layer API (addLayer) and adds zero
8
+ * features to the core.
9
+ *
10
+ * import { attachSessions } from 'wickchart-sessions';
11
+ * const sessions = attachSessions(chart, { preset: 'crypto' });
12
+ * sessions.setPreset('nyse'); // crypto | forex | nyse | cme | null
13
+ * sessions.setSessions([...]); // custom defs (see README) — wins over preset
14
+ * sessions.setWeekends(true); // shade closed weekends
15
+ * sessions.setLabels(false);
16
+ * sessions.detach();
17
+ *
18
+ * Events on the chart element:
19
+ * wick:sessions { detail: { hover: name|null } } — the session under the
20
+ * crosshair changed (bridged from the chart's own crosshair events, so
21
+ * this layer never claims a pointer gesture)
22
+ */
23
+ import {
24
+ PRESETS,
25
+ WEEKEND_PRESETS,
26
+ DEFAULT_COLORS,
27
+ WEEKEND_COLOR,
28
+ normalizeSessions,
29
+ bandsFor,
30
+ weekendBands,
31
+ } from './core.mjs';
32
+
33
+ const FONT = '600 10px ui-sans-serif, system-ui, sans-serif';
34
+ const LABEL_MIN = 28; // px of band width below which labels are skipped
35
+
36
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
37
+
38
+ export function attachSessions(chart, opts = {}) {
39
+ return new SessionsLayer(chart, opts);
40
+ }
41
+
42
+ export class SessionsLayer {
43
+ constructor(chart, opts = {}) {
44
+ if (!chart || typeof chart.addLayer !== 'function') {
45
+ throw new TypeError('attachSessions(chart): the chart element is required');
46
+ }
47
+ this._chart = chart;
48
+ this._labels = opts.labels !== false;
49
+ this._alpha = isNum(opts.opacity) ? Math.max(0, Math.min(1, opts.opacity)) : 0.08;
50
+ this._weekendTz = typeof opts.weekendTz === 'string' ? opts.weekendTz : null;
51
+ this._preset = null;
52
+ this._defs = [];
53
+ this._weekends = false;
54
+ this._bands = []; // bands from the last render, for hover lookup
55
+ this._hover = null;
56
+ this._layer = { id: 'wick-sessions', draw: (api) => this._render(api) };
57
+ chart.addLayer(this._layer);
58
+ this._onCross = (e) => this._cross(e.detail);
59
+ chart.addEventListener('wick:crosshair', this._onCross);
60
+ if (opts.preset) this.setPreset(opts.preset);
61
+ if (opts.sessions) this.setSessions(opts.sessions);
62
+ if (opts.weekends != null) this.setWeekends(opts.weekends);
63
+ }
64
+
65
+ /* ---------------- public API ---------------- */
66
+
67
+ /**
68
+ * Apply a preset: 'crypto' | 'forex' | 'nyse' | 'cme' | null (none).
69
+ * nyse/cme default to weekend shading; pass `{ weekends: false }`… or
70
+ * call setWeekends(false) after.
71
+ */
72
+ setPreset(name, presetOpts = {}) {
73
+ if (name == null) {
74
+ this._preset = null;
75
+ this._defs = [];
76
+ this._redraw();
77
+ return this;
78
+ }
79
+ const defs = PRESETS[name];
80
+ if (!defs) return this;
81
+ this._preset = name;
82
+ this._defs = normalizeSessions(defs);
83
+ this._weekends = presetOpts.weekends != null ? presetOpts.weekends !== false : WEEKEND_PRESETS.has(name);
84
+ this._redraw();
85
+ return this;
86
+ }
87
+
88
+ get preset() {
89
+ return this._preset;
90
+ }
91
+
92
+ /** Replace the session defs with a custom list (invalid entries dropped). */
93
+ setSessions(list) {
94
+ this._preset = null;
95
+ this._defs = normalizeSessions(list);
96
+ this._redraw();
97
+ return this;
98
+ }
99
+
100
+ /** Deep copy of the active defs — JSON-serializable. */
101
+ getSessions() {
102
+ return this._defs.map((d) => ({ ...d, days: d.days ? [...d.days] : null }));
103
+ }
104
+
105
+ setLabels(on) {
106
+ this._labels = on !== false;
107
+ this._redraw();
108
+ return this;
109
+ }
110
+
111
+ get labels() {
112
+ return this._labels;
113
+ }
114
+
115
+ /** Shade closed weekends (Sat+Sun) in `tz` — defaults to the first session's tz. */
116
+ setWeekends(on, tz) {
117
+ this._weekends = on !== false;
118
+ if (typeof tz === 'string') this._weekendTz = tz;
119
+ this._redraw();
120
+ return this;
121
+ }
122
+
123
+ get weekends() {
124
+ return this._weekends;
125
+ }
126
+
127
+ /** Fill opacity for all bands (0..1; custom defs may override per-session). */
128
+ setOpacity(a) {
129
+ if (isNum(a)) this._alpha = Math.max(0, Math.min(1, a));
130
+ this._redraw();
131
+ return this;
132
+ }
133
+
134
+ detach() {
135
+ this._chart.removeEventListener('wick:crosshair', this._onCross);
136
+ try {
137
+ this._chart.removeLayer('wick-sessions');
138
+ } catch (_) {}
139
+ this._chart = null;
140
+ }
141
+
142
+ /* ---------------- hover (crosshair bridge) ---------------- */
143
+
144
+ _cross(detail) {
145
+ if (!this._chart) return;
146
+ let t = detail && detail.bar ? detail.bar.time : null;
147
+ if (!isNum(t)) t = null;
148
+ else if (t < 1e12) t *= 1000;
149
+ const name = t == null ? null : this._nameAt(t);
150
+ if (name !== this._hover) {
151
+ this._hover = name;
152
+ this._chart.dispatchEvent(new CustomEvent('wick:sessions', { detail: { hover: name } }));
153
+ }
154
+ }
155
+
156
+ _nameAt(t) {
157
+ for (const b of this._bands) {
158
+ if (b.def.name && t >= b.start && t < b.end) return b.def.name;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /* ---------------- render ---------------- */
164
+
165
+ _redraw() {
166
+ if (this._chart && typeof this._chart.requestDraw === 'function') this._chart.requestDraw();
167
+ }
168
+
169
+ _weekendTzResolved() {
170
+ if (this._weekendTz) return this._weekendTz;
171
+ return (this._defs[0] && this._defs[0].tz) || 'UTC';
172
+ }
173
+
174
+ _render(api) {
175
+ const { ctx, layout, palette: pal, data } = api;
176
+ this._bands = [];
177
+ if (!data.length || (!this._defs.length && !this._weekends)) return;
178
+ const main = layout.main;
179
+ const t0 = api.xToTime(0);
180
+ const t1 = api.xToTime(layout.plotRight);
181
+ if (!isNum(t0) || !isNum(t1) || t1 <= t0) return;
182
+
183
+ const bands = bandsFor(this._defs, t0, t1);
184
+ if (this._weekends) {
185
+ const wt = this._weekendTzResolved();
186
+ for (const b of weekendBands(t0, t1, wt)) {
187
+ bands.push({ def: { name: '', color: WEEKEND_COLOR, alpha: null }, start: b.start, end: b.end });
188
+ }
189
+ }
190
+ // weekends under sessions: draw gray first, session tints on top
191
+ bands.sort((a, b) => (a.def.name ? 1 : 0) - (b.def.name ? 1 : 0));
192
+ // uncolored sessions cycle through the default tints, in def order
193
+ const tint = new Map();
194
+ this._defs.forEach((d, i) => {
195
+ if (!d.color) tint.set(d, DEFAULT_COLORS[i % DEFAULT_COLORS.length]);
196
+ });
197
+ this._bands = bands;
198
+ if (!bands.length) return;
199
+
200
+ ctx.save();
201
+ ctx.beginPath();
202
+ ctx.rect(0, main.y0, layout.plotRight + 1, main.h);
203
+ ctx.clip();
204
+ ctx.font = FONT;
205
+ ctx.textAlign = 'left';
206
+ ctx.textBaseline = 'top';
207
+
208
+ for (const b of bands) {
209
+ const x1 = api.timeToX(b.start);
210
+ const x2 = api.timeToX(b.end);
211
+ if (!isNum(x1) || !isNum(x2)) continue;
212
+ const ax = Math.round(x1);
213
+ const bx = Math.round(x2);
214
+ if (bx <= ax) continue;
215
+ const color = this._colorOf(b.def, pal, tint);
216
+ ctx.globalAlpha = b.def.alpha != null ? b.def.alpha : this._alpha;
217
+ ctx.fillStyle = color;
218
+ ctx.fillRect(ax, main.y0, bx - ax, main.h);
219
+ if (this._labels && b.def.name && bx - ax >= LABEL_MIN) this._label(ctx, b.def.name, ax, main.y0, bx - ax, color, pal.bg);
220
+ }
221
+ ctx.restore();
222
+ ctx.globalAlpha = 1;
223
+ }
224
+
225
+ _colorOf(def, pal, tint) {
226
+ if (def.color === 'up' || def.color === 'down' || def.color === 'accent') return pal[def.color];
227
+ if (def.color) return def.color;
228
+ return tint.get(def) || DEFAULT_COLORS[0];
229
+ }
230
+
231
+ _label(ctx, name, ax, y, w, color, bg) {
232
+ const fit = (s) => (ctx.measureText(s).width <= w - 10 ? s : null);
233
+ let text = fit(name);
234
+ if (!text) {
235
+ // binary-search the longest prefix that fits (names are ≤ 24 chars)
236
+ let lo = 0;
237
+ let hi = name.length;
238
+ while (lo < hi) {
239
+ const mid = (lo + hi + 1) >> 1;
240
+ if (fit(name.slice(0, mid) + '…')) lo = mid;
241
+ else hi = mid - 1;
242
+ }
243
+ text = lo > 0 ? name.slice(0, lo) + '…' : null;
244
+ }
245
+ if (!text) return;
246
+ ctx.globalAlpha = 1;
247
+ ctx.strokeStyle = bg;
248
+ ctx.lineWidth = 3;
249
+ ctx.strokeText(text, ax + 5, y + 4);
250
+ ctx.fillStyle = color;
251
+ ctx.fillText(text, ax + 5, y + 4);
252
+ }
253
+ }