mnfst 0.5.163 → 0.5.165

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.
@@ -0,0 +1,292 @@
1
+ /* Manifest Appwrite Presences ($presence)
2
+ /* By Andrew Matlock under MIT license
3
+ /* https://github.com/andrewmatlock/Manifest
4
+ /*
5
+ /* Reactive user status/roster over the native Appwrite Presences API
6
+ /* Requires Alpine JS (alpinejs.dev) to operate
7
+ */
8
+
9
+ (function () {
10
+ 'use strict';
11
+
12
+ /* State */
13
+
14
+ const ctx = {
15
+ client: null,
16
+ presences: null,
17
+ realtime: null,
18
+ sub: null,
19
+ heartbeat: null,
20
+ started: false,
21
+ status: 'online',
22
+ metadata: null,
23
+ auto: true, // focus/blur auto-flip
24
+ heartbeatMs: 25000, // TTL refresh cadence
25
+ onFocus: null,
26
+ onBlur: null
27
+ };
28
+
29
+ /* Config */
30
+
31
+ // Resolve ${VAR} against window.env
32
+ function resolveEnv(v) {
33
+ if (typeof v !== 'string') return v;
34
+ return v.replace(/\$\{([^}]+)\}/g, (m, name) => {
35
+ if (typeof window !== 'undefined' && window.env && window.env[name] != null) return window.env[name];
36
+ return null;
37
+ });
38
+ }
39
+
40
+ async function getConfig() {
41
+ try {
42
+ const manifest = await window.ManifestDataConfig?.ensureManifest?.();
43
+ const aw = manifest?.appwrite;
44
+ if (!aw) return null;
45
+ const endpoint = resolveEnv(aw.endpoint);
46
+ const projectId = resolveEnv(aw.projectId);
47
+ if (!endpoint || !projectId) return null;
48
+ let devKey = aw.devKey ? resolveEnv(aw.devKey) : null;
49
+ if (devKey && /\$\{/.test(devKey)) devKey = null;
50
+ return { endpoint, projectId, devKey };
51
+ } catch (e) {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function store() {
57
+ return (typeof Alpine !== 'undefined') ? Alpine.store('presence') : null;
58
+ }
59
+
60
+ function authUser() {
61
+ const s = (typeof Alpine !== 'undefined') ? Alpine.store('auth') : null;
62
+ return s && s.user ? s.user : null;
63
+ }
64
+
65
+ // Active team id (read-scoping)
66
+ function currentTeamId() {
67
+ const s = (typeof Alpine !== 'undefined') ? Alpine.store('auth') : null;
68
+ if (!s) return null;
69
+ if (s.currentTeam) return typeof s.currentTeam === 'string' ? s.currentTeam : (s.currentTeam.$id || s.currentTeam.id || null);
70
+ if (Array.isArray(s.teams) && s.teams.length) return s.teams[0].$id || s.teams[0].id || null;
71
+ return null;
72
+ }
73
+
74
+ // Consistent per-user color
75
+ function userColor(id) {
76
+ if (!id) return '#666';
77
+ let hash = 0;
78
+ for (let i = 0; i < id.length; i++) hash = id.charCodeAt(i) + ((hash << 5) - hash);
79
+ return `hsl(${Math.abs(hash) % 360}, 70%, 50%)`;
80
+ }
81
+
82
+ // Appwrite client + services — authed via the session cookie, not the dev key (which is the guests principal for user ops)
83
+ async function ensureServices() {
84
+ if (ctx.presences && ctx.realtime) return true;
85
+ const A = window.Appwrite;
86
+ if (!A || !A.Presences || !A.Realtime) return false;
87
+ const cfg = await getConfig();
88
+ if (!cfg) return false;
89
+ const client = new A.Client().setEndpoint(cfg.endpoint).setProject(cfg.projectId);
90
+ ctx.client = client;
91
+ ctx.presences = new A.Presences(client);
92
+ ctx.realtime = new A.Realtime(client);
93
+ return true;
94
+ }
95
+
96
+ /* Records */
97
+
98
+ function normalize(rec) {
99
+ if (!rec) return null;
100
+ const userId = rec.userId || rec.$userId || rec.presenceId || rec.$id;
101
+ if (!userId) return null;
102
+ let metadata = rec.metadata;
103
+ if (typeof metadata === 'string') { try { metadata = JSON.parse(metadata); } catch (e) { } }
104
+ const updatedAt = rec.$updatedAt || rec.$createdAt || null;
105
+ return {
106
+ userId,
107
+ status: rec.status || null,
108
+ metadata: metadata || null,
109
+ lastSeen: updatedAt ? Date.parse(updatedAt) : Date.now(),
110
+ expiresAt: rec.expiresAt || rec.$expiresAt || null,
111
+ color: userColor(userId)
112
+ };
113
+ }
114
+
115
+ // Reactive map updates (reassign for Alpine tracking)
116
+ function putRecord(rec) {
117
+ const s = store(); if (!s || !rec) return;
118
+ s.records = { ...s.records, [rec.userId]: rec };
119
+ }
120
+ function removeRecord(userId) {
121
+ const s = store(); if (!s || !userId) return;
122
+ if (!s.records[userId]) return;
123
+ const next = { ...s.records }; delete next[userId]; s.records = next;
124
+ }
125
+
126
+ async function hydrate() {
127
+ const s = store();
128
+ try {
129
+ const res = await ctx.presences.list();
130
+ const rows = res?.presences || res?.rows || res?.documents || [];
131
+ const map = {};
132
+ rows.forEach(r => { const n = normalize(r); if (n) map[n.userId] = n; });
133
+ if (s) s.records = map;
134
+ } catch (e) {
135
+ if (s) s.error = e?.message || String(e);
136
+ }
137
+ }
138
+
139
+ function subscribe() {
140
+ const A = window.Appwrite;
141
+ try {
142
+ const handle = ctx.realtime.subscribe(A.Channel.presences(), (response) => {
143
+ if (!response) return;
144
+ const events = Array.isArray(response.events) ? response.events : (response.events ? [response.events] : []);
145
+ const rec = normalize(response.payload || response);
146
+ if (!rec) return;
147
+ // Ignore echoes of our own presence (tracked locally; delayed echoes would clobber newer status)
148
+ const meId = authUser()?.$id;
149
+ if (meId && rec.userId === meId) return;
150
+ if (events.some(e => typeof e === 'string' && e.includes('delete'))) removeRecord(rec.userId);
151
+ else putRecord(rec);
152
+ });
153
+ // Normalise the unsubscribe handle
154
+ if (handle && typeof handle.then === 'function') {
155
+ handle.then(r => { ctx.sub = typeof r === 'function' ? r : (r && r.close ? () => r.close() : null); });
156
+ ctx.sub = () => { handle.then(r => { if (typeof r === 'function') r(); else if (r && r.close) r.close(); }); };
157
+ } else if (typeof handle === 'function') {
158
+ ctx.sub = handle;
159
+ } else if (handle && typeof handle.close === 'function') {
160
+ ctx.sub = () => handle.close();
161
+ }
162
+ } catch (e) {
163
+ const s = store(); if (s) s.error = e?.message || String(e);
164
+ }
165
+ }
166
+
167
+ /* Operations */
168
+
169
+ async function set(status, metadata) {
170
+ const A = window.Appwrite;
171
+ const user = authUser();
172
+ const s = store();
173
+ if (!user) { if (s) s.error = 'sign in required'; return false; }
174
+ if (!(await ensureServices())) { if (s) s.error = 'Appwrite Presences unavailable'; return false; }
175
+ ctx.status = status != null ? status : ctx.status;
176
+ ctx.metadata = metadata !== undefined ? metadata : ctx.metadata;
177
+
178
+ // Read-scope + owner write (owner update/delete required, else updates 401)
179
+ const teamId = currentTeamId();
180
+ const permissions = [
181
+ teamId ? A.Permission.read(A.Role.team(teamId)) : A.Permission.read(A.Role.users()),
182
+ A.Permission.update(A.Role.user(user.$id)),
183
+ A.Permission.delete(A.Role.user(user.$id))
184
+ ];
185
+
186
+ // name/color in metadata for rosters (name null when unknown — display label is the author's)
187
+ const meta = Object.assign(
188
+ { name: user.name || user.email || null, color: userColor(user.$id) },
189
+ ctx.metadata || {}
190
+ );
191
+
192
+ // Optimistic local update
193
+ putRecord(normalize({ userId: user.$id, status: ctx.status, metadata: meta, $updatedAt: new Date().toISOString() }));
194
+ if (s) s.me = ctx.status;
195
+ try {
196
+ await ctx.presences.upsert({ presenceId: user.$id, status: ctx.status, metadata: meta, permissions });
197
+ if (s) s.error = null;
198
+ return true;
199
+ } catch (e) {
200
+ if (s) s.error = e?.message || String(e);
201
+ return false;
202
+ }
203
+ }
204
+
205
+ async function clear() {
206
+ const user = authUser();
207
+ if (!user || !ctx.presences) return;
208
+ try { await ctx.presences.delete({ presenceId: user.$id }); } catch (e) { }
209
+ removeRecord(user.$id);
210
+ const s = store(); if (s) s.me = null;
211
+ }
212
+
213
+ function of(userId) {
214
+ const s = store();
215
+ return (s && s.records ? s.records[userId] : null) || null;
216
+ }
217
+
218
+ function all() {
219
+ const s = store();
220
+ return s && s.records ? Object.values(s.records) : [];
221
+ }
222
+
223
+ async function start(options) {
224
+ const opts = options || {};
225
+ if (typeof opts.auto === 'boolean') ctx.auto = opts.auto;
226
+ if (typeof opts.heartbeatMs === 'number') ctx.heartbeatMs = opts.heartbeatMs;
227
+ if (typeof opts.status === 'string') ctx.status = opts.status;
228
+
229
+ const s = store();
230
+ if (!authUser()) { if (s) s.error = 'sign in required'; return false; }
231
+ if (!(await ensureServices())) { if (s) s.error = 'Appwrite Presences unavailable'; return false; }
232
+ if (ctx.started) return true;
233
+ ctx.started = true;
234
+
235
+ await hydrate();
236
+ subscribe();
237
+ await set(ctx.status, ctx.metadata);
238
+
239
+ // Heartbeat (refresh TTL)
240
+ ctx.heartbeat = setInterval(() => { if (authUser()) set(ctx.status, ctx.metadata); }, ctx.heartbeatMs);
241
+
242
+ // Auto focus/blur flip
243
+ if (ctx.auto) {
244
+ ctx.onBlur = () => set('away');
245
+ ctx.onFocus = () => set('online');
246
+ window.addEventListener('blur', ctx.onBlur);
247
+ window.addEventListener('focus', ctx.onFocus);
248
+ }
249
+
250
+ if (s) { s.ready = true; s.error = null; }
251
+ return true;
252
+ }
253
+
254
+ function stop() {
255
+ if (ctx.heartbeat) { clearInterval(ctx.heartbeat); ctx.heartbeat = null; }
256
+ if (ctx.onBlur) { window.removeEventListener('blur', ctx.onBlur); ctx.onBlur = null; }
257
+ if (ctx.onFocus) { window.removeEventListener('focus', ctx.onFocus); ctx.onFocus = null; }
258
+ if (typeof ctx.sub === 'function') { try { ctx.sub(); } catch (e) { } }
259
+ ctx.sub = null;
260
+ ctx.started = false;
261
+ const s = store(); if (s) s.ready = false;
262
+ }
263
+
264
+ /* Registration */
265
+
266
+ document.addEventListener('alpine:init', () => {
267
+ Alpine.store('presence', { records: {}, ready: false, error: null, me: null });
268
+
269
+ // Clear on sign-out
270
+ try {
271
+ Alpine.effect(() => {
272
+ if (!authUser() && ctx.started) { clear(); stop(); }
273
+ });
274
+ } catch (e) { }
275
+
276
+ const api = {
277
+ start, stop, set, clear, of, all,
278
+ get records() { return store()?.records || {}; },
279
+ get list() { return all(); },
280
+ get ready() { return !!store()?.ready; },
281
+ get error() { return store()?.error || null; },
282
+ get me() { return store()?.me || null; }
283
+ };
284
+ Alpine.magic('presence', () => api);
285
+ });
286
+
287
+ // Clear on tab close
288
+ window.addEventListener('beforeunload', () => { try { if (ctx.started) clear(); } catch (e) { } });
289
+
290
+ // Non-Alpine handle
291
+ window.ManifestPresence = { start, stop, set, clear, of, all };
292
+ })();
@@ -1,33 +1,18 @@
1
- /* Manifest Charts
1
+ /* Manifest Charts — in-house SVG chart renderer (themeable, prerender-safe, a11y).
2
2
  /* By Andrew Matlock under MIT license
3
3
  /* https://manifestx.dev
4
- /*
5
- /* An in-house SVG chart renderer. SVG (not canvas) so charts inherit theme
6
- /* colors via CSS variables, are restylable with the same selector
7
- /* conventions as every other element, survive static prerendering as real
8
- /* DOM, and expose accessible <title>/<desc>. The only dependencies are the
9
- /* d3-scale / d3-shape / d3-array micro-modules (ISC, ~22KB), lazy-loaded
10
- /* from esm.run on first scroll-into-view — the same posture as the code
11
- /* plugin loading highlight.js on demand.
4
+ /* d3-scale/shape/array micro-modules (ISC) lazy-loaded from esm.run on scroll-in.
12
5
  */
13
6
 
14
7
  (function () {
15
8
  'use strict';
16
9
 
17
- /* ------------------------------------------------------------------ *
18
- * Shared global: ManifestUI (universal `_ui` resolver). Defined guarded so
19
- * charts works whether or not the date picker (which also defines it) is
20
- * loaded. `_ui` is a reserved, self-identifying key: any loaded data source
21
- * may carry a top-level `_ui` object, namespaced per element (`_ui.charts`,
22
- * `_ui.colorpicker`, …); no manifest flag — overrides piggyback on the normal
23
- * local-data/localization model. resolve() deep-merges every loaded source's
24
- * `_ui[component]` onto the plugin's English fallbacks. Kept byte-identical
25
- * across the date picker / color picker copies.
26
- * ------------------------------------------------------------------ */
10
+ /* Shared localized-UI resolver: deep-merges every loaded source's `_ui[component]`
11
+ * onto the plugin's English fallbacks. Guarded; byte-identical across the picker
12
+ * copies (first plugin to load defines it). */
27
13
  if (!window.ManifestUI) {
28
14
  window.ManifestUI = {
29
- /* Names of data sources that have loaded (current locale). Enumerates loaded
30
- * sources only — never force-loads others just to scan them for `_ui`. */
15
+ /* Loaded data sources (current locale); never force-loads others. */
31
16
  _loadedSourceNames() {
32
17
  try {
33
18
  const store = window.ManifestDataStore && window.ManifestDataStore.rawDataStore;
@@ -35,8 +20,7 @@
35
20
  } catch (_) { }
36
21
  return [];
37
22
  },
38
- /* Deep-merge every loaded source's `_ui[component]` onto `fallbacks`.
39
- * Reads inside the caller's Alpine effect (if any) so $x/$locale make it reactive. */
23
+ /* Reads inside the caller's Alpine effect (if any) so $x/$locale stay reactive. */
40
24
  resolve(component, fallbacks) {
41
25
  const merged = JSON.parse(JSON.stringify(fallbacks || {}));
42
26
  try {
@@ -256,19 +240,15 @@
256
240
  t.appendChild(document.createTextNode(str == null ? '' : String(str))); // untrusted-safe
257
241
  return t;
258
242
  }
259
- // Entry animations run only on a chart's first draw. Reactive redraws
260
- // (a bound value changing, a resize) must paint the final state directly
261
- // — otherwise dragging a slider replays the reveal every frame and the
262
- // chart flickers. drawChart sets this before dispatching.
243
+ // Entry animations run only on first draw; redraws paint final state directly
244
+ // (else a bound value change replays the reveal every frame). Set by drawChart.
263
245
  let _suppressAnim = false;
264
246
  function animate(el, keyframes, opts) {
265
247
  if (_suppressAnim || prefersReducedMotion() || typeof el.animate !== 'function') return;
266
248
  try { el.animate(keyframes, Object.assign({ duration: 600, easing: 'cubic-bezier(0.22,1,0.36,1)', fill: 'backwards' }, opts)); } catch (_) { }
267
249
  }
268
- // Cursor-following tooltip. Manifest's x-tooltip relies on CSS anchor
269
- // positioning, which can't anchor to SVG child elements (no CSS-layout
270
- // box) — so charts use their own tip, themed to match, following the
271
- // pointer (better UX for dense charts). aria-label carries AT semantics.
250
+ // Cursor-following tooltip: x-tooltip's CSS anchor positioning can't target SVG
251
+ // children (no layout box), so charts use their own themed tip. aria-label for AT.
272
252
  function applyTip(seg, tip, cfg) {
273
253
  seg.setAttribute('aria-label', tip);
274
254
  if (!cfg.tooltip) return;
@@ -337,12 +317,8 @@
337
317
  if (self.el.id) _registry[self.el.id] = self.api;
338
318
  },
339
319
 
340
- // Coalesce to one draw per tick. Uses setTimeout (not rAF) so draws
341
- // still happen when the tab is backgrounded (rAF is paused for hidden
342
- // tabs), and does NOT reset a pending timer on re-entry — a
343
- // high-frequency reactive trigger (e.g. a plugin bumping the data-store
344
- // version every tick) would otherwise perpetually reschedule and starve
345
- // the draw.
320
+ // One draw per tick. setTimeout (not rAF) so backgrounded tabs still draw;
321
+ // doesn't reset a pending timer, so a high-frequency trigger can't starve it.
346
322
  schedule() { if (this._t) return; this._t = setTimeout(() => { this._t = null; this.draw(); }, 0); },
347
323
 
348
324
  renderError(msg) { this.el.innerHTML = ''; const d = document.createElement('small'); d.textContent = msg; this.el.appendChild(d); },
@@ -407,10 +383,8 @@
407
383
  return 4 + n * cfg.rowHeight + 22;
408
384
  }
409
385
 
410
- // Palette size is CSS-driven: count consecutive --color-chart-N custom
411
- // properties (themes can extend past 8 by defining --color-chart-9, …);
412
- // segment colours cycle through however many exist. Re-probed per draw
413
- // so per-scope overrides apply.
386
+ // Palette size = count of consecutive --color-chart-N tokens (themes may extend
387
+ // past 8); colours cycle through them. Re-probed per draw for per-scope overrides.
414
388
  let _paletteN = 8;
415
389
  function probePalette(el) {
416
390
  try {
@@ -649,13 +623,8 @@
649
623
  if (cfg.legend) drawLegend(state, labels);
650
624
  }
651
625
 
652
- // Gauge a single value swept across a 180° dome. Track + value arc
653
- // reuse the pie/donut arc primitive; optional `zones` paint threshold
654
- // bands (e.g. positive/warning/negative ranges). `unit` suffixes the
655
- // centered readout; `min`/`max` default 0–100.
656
- // Widest rendered label in px — exact via getComputedTextLength (the root
657
- // SVG is already in the document); char-estimate fallback pre-layout. Used
658
- // to size axis gutters to their content so charts sit balanced, not lopsided.
626
+ // Widest rendered label in px (exact via getComputedTextLength, char-estimate
627
+ // fallback). Sizes axis gutters to their content so charts sit balanced.
659
628
  function labelWidth(root, items, cls) {
660
629
  if (!items || !items.length) return 0;
661
630
  const probe = svg('text', cls ? { class: cls, x: -9999, y: -9999 } : { x: -9999, y: -9999 }, root);
@@ -694,15 +663,12 @@
694
663
  svg('path', { class: 'gauge-track', d: arc({ startAngle: START, endAngle: END }) }, g);
695
664
  }
696
665
 
697
- // Value arc — final geometry is set unconditionally so the gauge is
698
- // correct even if the entry animation never runs (background tab);
699
- // the sweep is a CSS reveal, never the source of the end state.
666
+ // Value arc — final geometry set unconditionally, so a background tab that
667
+ // skips the fade reveal still renders correctly.
700
668
  const color = (cfg.series[0] && cfg.series[0].color) || 'var(--color-chart-1)';
701
669
  const valueAngle = scale(value);
702
670
  const vArc = svg('path', { class: 'gauge-value', d: arc({ startAngle: START, endAngle: valueAngle }), style: `--color-chart-color:${color}` }, g);
703
671
  applyTip(vArc, (cfg.title ? cfg.title + ': ' : '') + value + unit, cfg);
704
- // Reveal via fade (WAAPI, origin-independent) — the final geometry above
705
- // is unconditional, so the gauge is correct even if this never runs.
706
672
  animate(vArc, [{ opacity: 0 }, { opacity: 1 }], { duration: 500 });
707
673
 
708
674
  // Centered readout + range end labels.
@@ -714,10 +680,8 @@
714
680
  }
715
681
  }
716
682
 
717
- // Heatmap — a matrix of cells: each series is a row, each datum a column
718
- // aligned to `labels`. Cell colour is a CSS color-mix between the two
719
- // --color-chart-heat-* tokens (no JS colour-interpolation dep), driven
720
- // by the per-cell `--heat` percentage.
683
+ // Heatmap — matrix of cells (row per series, column per datum). Cell colour is a
684
+ // CSS color-mix of the --color-chart-heat-* tokens via a per-cell `--heat` %.
721
685
  function drawHeatmap(state, root, width, height) {
722
686
  const cfg = state.config, d3 = state.d3;
723
687
  const rows = cfg.series;
@@ -747,9 +711,8 @@
747
711
 
748
712
  const plot = svg('g', { transform: `translate(${m.left},${m.top})` }, root);
749
713
 
750
- // Round only the grid's outer corners: square tiles clipped to a single
751
- // rounded rect hugging the cell extent (right/bottom edge sits at the
752
- // last tile's inner edge, so the radius isn't clipping empty gutter).
714
+ // Round only the grid's outer corners: square tiles clipped to one rounded
715
+ // rect hugging the cell extent (so the radius doesn't clip empty gutter).
753
716
  const clipId = 'mnfst-heat-' + (++_uid);
754
717
  const cp = svg('clipPath', { id: clipId }, svg('defs', null, root));
755
718
  svg('rect', { x: 0, y: 0, width: Math.max(0, iw - gap), height: Math.max(0, ih - gap), rx: cssRadius(state.el) }, cp);
@@ -790,12 +753,9 @@
790
753
  state.el.appendChild(footer);
791
754
  }
792
755
 
793
- // Gantt — each series is a track (row); its data is an array
794
- // of `{ from, to, status?, label?, color? }` segments on a shared X axis.
795
- // The axis adapts to the data: numbers linear (0–10, distance, %),
796
- // date-ish values → time, other strings → category (ordinal stages). A
797
- // segment with no `to` is a point marker. Single track = a status strip;
798
- // many tracks = swimlanes / Gantt.
756
+ // Gantt — each series is a track of `{ from, to, status?, label?, color? }`
757
+ // segments on a shared X axis, adapting numbers→linear, dates→time, else
758
+ // category. A `to`-less segment is a point marker.
799
759
  function drawGantt(state, root, width, height) {
800
760
  const cfg = state.config, d3 = state.d3;
801
761
  const tracks = cfg.series;
@@ -967,9 +927,8 @@
967
927
  const valueById = {};
968
928
  entries.forEach(([key, val]) => resolveRegion(key, geo).ids.forEach(id => { valueById[id] = val; }));
969
929
 
970
- // Display-name resolution: `_ui.map.regions` override → localized
971
- // pack (per $locale) → English atlas name. Async packs trigger a
972
- // redraw when they arrive.
930
+ // Display name: `_ui.map.regions` override → localized pack ($locale) →
931
+ // English atlas name. Async packs trigger a redraw on arrival.
973
932
  const num2a2 = (geo.meta && geo.meta.num2a2) || {};
974
933
  const locale = chartLocale();
975
934
  const ui = (window.ManifestUI && window.ManifestUI.resolve) ? window.ManifestUI.resolve('map', {}) : {};
@@ -986,9 +945,8 @@
986
945
  return eng;
987
946
  };
988
947
 
989
- // Author-supplied place gazetteer (config `places` or `_ui.map.cities`)
990
- // for resolving point names → coords. No city data is bundled; authors
991
- // source it from a third-party library in their own project.
948
+ // Author-supplied place gazetteer (config `places` or `_ui.map.cities`) for
949
+ // resolving point names → coords. No city data is bundled.
992
950
  const placesSrc = cfg.places || ui.cities || null;
993
951
  let places = null;
994
952
  if (placesSrc) { places = {}; Object.keys(placesSrc).forEach(k => { places[k.toLowerCase()] = placesSrc[k]; }); }
@@ -1066,9 +1024,8 @@
1066
1024
  state.el.appendChild(footer);
1067
1025
  }
1068
1026
 
1069
- // Legend is a <footer> sibling below the SVG (inline flex), not an
1070
- // absolute overlay — so it never collides with axis labels. Each item is
1071
- // a <span> with an <i> swatch carrying the series colour.
1027
+ // Legend is a <footer> sibling below the SVG, not an overlay, so it never
1028
+ // collides with axis labels. Each item: a <span> with an <i> colour swatch.
1072
1029
  function drawLegend(state, labelsOverride) {
1073
1030
  const cfg = state.config;
1074
1031
  const items = labelsOverride || cfg.series.map(s => s.name).filter(Boolean);
@@ -1091,10 +1048,8 @@
1091
1048
  const state = createState(el, expression, modifiers);
1092
1049
  el._chartState = state;
1093
1050
  el.classList.add('chart');
1094
- // Reserve the chart's height up front (parsed from the config, default
1095
- // 240) so the empty container isn't 0-height a zero-height box doesn't
1096
- // reliably trigger the lazy-load IntersectionObserver, and reserving it
1097
- // also prevents a layout jump when the SVG renders.
1051
+ // Reserve height up front (config, default 240): a 0-height box doesn't
1052
+ // reliably trigger the lazy-load observer, and reserving avoids a layout jump.
1098
1053
  const hm = expression && /height\s*:\s*(\d+)/.exec(expression);
1099
1054
  el.style.minHeight = (hm ? +hm[1] : 240) + 'px';
1100
1055
  observer().observe(el);