mnfst 0.5.158 → 0.5.160

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.
@@ -73,7 +73,7 @@
73
73
  stroke-width: 1.5;
74
74
  transition: r var(--transition-duration, .1s) ease;
75
75
 
76
- &:hover {
76
+ &:not(.map-point):hover {
77
77
  r: 5
78
78
  }
79
79
  }
@@ -142,6 +142,37 @@
142
142
  pointer-events: none
143
143
  }
144
144
 
145
+ /* Timeline / Gantt */
146
+ & rect.gantt-segment {
147
+ fill: var(--color-chart-color, var(--color-chart-1));
148
+ transition: opacity var(--transition-duration, .1s) ease;
149
+
150
+ &:hover {
151
+ opacity: 0.82
152
+ }
153
+ }
154
+
155
+ & rect.gantt-point {
156
+ fill: var(--color-chart-color, var(--color-content-stark, oklch(20.5% 0 0)))
157
+ }
158
+
159
+ & text.gantt-track {
160
+ fill: var(--color-chart-label);
161
+ font-size: 0.75rem
162
+ }
163
+
164
+ /* Reference marker */
165
+ & line.gantt-marker {
166
+ stroke: var(--color-chart-color, var(--color-content-subtle, oklch(55.6% 0 0)));
167
+ stroke-width: 1.5;
168
+ stroke-dasharray: 3 3
169
+ }
170
+
171
+ & text.gantt-marker-label {
172
+ fill: var(--color-chart-color, var(--color-content-subtle, oklch(55.6% 0 0)));
173
+ font-size: 0.625rem
174
+ }
175
+
145
176
  /* Value labels drawn on/above segments */
146
177
  & text.value {
147
178
  fill: var(--color-content-stark, oklch(20.5% 0 0));
@@ -158,8 +189,32 @@
158
189
  }
159
190
  }
160
191
 
161
- /* Heatmap cell fill interpolates between the two heat tokens by the
162
- per-cell --heat percentage (0% = low, 100% = high) */
192
+ /* World map city / point markers */
193
+ & circle.map-point {
194
+ fill: var(--color-chart-color, var(--color-accent-content, oklch(20.5% 0 0)));
195
+ stroke: var(--color-page, oklch(98.5% 0 0));
196
+ stroke-width: 1;
197
+ fill-opacity: 0.82;
198
+ transition: opacity var(--transition-duration, .1s) ease;
199
+
200
+ &:hover {
201
+ opacity: 0.82
202
+ }
203
+ }
204
+
205
+ /* World map regions */
206
+ & path.map-region {
207
+ fill: var(--color-chart-color, var(--color-surface-2, color-mix(in oklch, oklch(20.5% 0 0) 8%, transparent)));
208
+ stroke: var(--color-page, oklch(98.5% 0 0));
209
+ stroke-width: 0.4;
210
+ transition: opacity var(--transition-duration, .1s) ease;
211
+
212
+ &:hover {
213
+ opacity: 0.82
214
+ }
215
+ }
216
+
217
+ /* Heatmap cell */
163
218
  & rect.heat-cell {
164
219
  fill: color-mix(in oklch, var(--color-chart-heat-high, var(--color-chart-1)) var(--heat, 0%), var(--color-chart-heat-low, var(--color-surface-2)));
165
220
  transition: opacity var(--transition-duration, .1s) ease;
@@ -169,7 +224,7 @@
169
224
  }
170
225
  }
171
226
 
172
- /* Legend — <footer> sibling below the SVG; <span> items, <i> swatches */
227
+ /* Legend */
173
228
  & footer {
174
229
  display: flex;
175
230
  flex-flow: row wrap;
@@ -194,9 +249,7 @@
194
249
  }
195
250
  }
196
251
 
197
- /* Heatmap gradient legend — low label, ramp bar (spans the grid), high
198
- label. Footer is padded to the chart margins in JS so the bar aligns
199
- with the columns. */
252
+ /* Heatmap gradient legend */
200
253
  & footer.heat-legend {
201
254
  flex-wrap: nowrap;
202
255
  gap: 0.5rem;
@@ -209,8 +262,7 @@
209
262
  }
210
263
  }
211
264
 
212
- /* Cursor-following tooltip — chrome comes from manifest.tooltip.css
213
- (.tooltip); only the pointer-tracking positioning lives here */
265
+ /* Cursor-following tooltip */
214
266
  & .tooltip {
215
267
  position: absolute;
216
268
  left: 0;
@@ -68,6 +68,18 @@
68
68
 
69
69
  const SVGNS = 'http://www.w3.org/2000/svg';
70
70
 
71
+ // Lazy localized country names per locale (i18n-iso-countries, keyed by
72
+ // alpha-2). Falls back to {} (English atlas names) if a locale isn't covered.
73
+ const _namePacks = {}, _namePackPromise = {};
74
+ function loadCountryNames(locale) {
75
+ if (_namePacks[locale]) return Promise.resolve(_namePacks[locale]);
76
+ if (_namePackPromise[locale]) return _namePackPromise[locale];
77
+ _namePackPromise[locale] = fetch('https://cdn.jsdelivr.net/npm/i18n-iso-countries@7/langs/' + locale + '.json')
78
+ .then(r => r.json()).then(d => (_namePacks[locale] = d.countries || {}))
79
+ .catch(() => (_namePacks[locale] = {}));
80
+ return _namePackPromise[locale];
81
+ }
82
+
71
83
  /* ---- Lazy-load d3 micro-modules once ---------------------------- */
72
84
  let d3Promise = null;
73
85
  function loadD3() {
@@ -87,6 +99,42 @@
87
99
 
88
100
  const prefersReducedMotion = () => { try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (_) { return false; } };
89
101
 
102
+ // Reconcile the ~20 Natural Earth atlas names that differ from the
103
+ // country-by-continent source so every rendered country lands in a continent.
104
+ const _norm = s => String(s || '').toLowerCase().replace(/\./g, '').replace(/\s+/g, ' ').trim();
105
+ const ATLAS_CONT = { 'fiji': 'Oceania', 'w sahara': 'Africa', 'united states of america': 'North America', 'dem rep congo': 'Africa', 'dominican rep': 'North America', 'falkland is': 'South America', 'fr s antarctic lands': 'Antarctica', 'timor-leste': 'Asia', "côte d'ivoire": 'Africa', 'central african rep': 'Africa', 'eq guinea': 'Africa', 'solomon is': 'Oceania', 'taiwan': 'Asia', 'czechia': 'Europe', 'n cyprus': 'Asia', 'somaliland': 'Africa', 'bosnia and herz': 'Europe', 'macedonia': 'Europe', 'kosovo': 'Europe', 's sudan': 'Africa' };
106
+
107
+ /* ---- World map: lazy-load d3-geo + topojson + reference data ----
108
+ Geometry (world-atlas) plus ISO codes (i18n-iso-countries) and
109
+ continent membership (country-json) — all third-party CDN, fetched
110
+ once and indexed onto `geo.meta`. No geographic tables are bundled. */
111
+ let geoPromise = null;
112
+ function loadGeo() {
113
+ if (window.__manifestGeo) return Promise.resolve(window.__manifestGeo);
114
+ if (geoPromise) return geoPromise;
115
+ geoPromise = Promise.all([
116
+ import('https://esm.run/d3-geo@3'),
117
+ import('https://esm.run/topojson-client@3'),
118
+ fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json').then(r => r.json()),
119
+ fetch('https://cdn.jsdelivr.net/npm/i18n-iso-countries@7/codes.json').then(r => r.json()),
120
+ fetch('https://cdn.jsdelivr.net/npm/country-json/src/country-by-continent.json').then(r => r.json())
121
+ ]).then(([geo, topo, world, codes, contList]) => {
122
+ const a2 = {}, a3 = {}, num2a2 = {};
123
+ codes.forEach(c => { const n = Number(c[2]); if (!n) return; a2[c[0]] = n; a3[c[1]] = n; num2a2[n] = c[0]; });
124
+ const nameToCont = {}; contList.forEach(r => { nameToCont[_norm(r.country)] = r.continent; });
125
+ const members = {}, idToCont = {};
126
+ (world.objects.countries.geometries || []).forEach(gm => {
127
+ const id = Number(gm.id), nm = _norm(gm.properties && gm.properties.name);
128
+ const cont = nameToCont[nm] || ATLAS_CONT[nm];
129
+ if (cont) { idToCont[id] = cont; (members[cont] = members[cont] || []).push(id); }
130
+ });
131
+ const g = { ...geo, topojson: topo, world, meta: { a2, a3, num2a2, members, idToCont } };
132
+ window.__manifestGeo = g;
133
+ return g;
134
+ }).catch((err) => { geoPromise = null; throw err; });
135
+ return geoPromise;
136
+ }
137
+
90
138
  function initializeChartsPlugin() {
91
139
  const Alpine = window.Alpine;
92
140
  const _registry = Alpine.reactive ? Alpine.reactive({}) : {};
@@ -116,6 +164,19 @@
116
164
  /* ---- Config normalization ----------------------------------- */
117
165
  function num(v, d) { const n = Number(v); return isNaN(n) ? d : n; }
118
166
 
167
+ // Timeline values: Date → ms, number → as-is, string → parsed date (or
168
+ // numeric fallback). NaN when absent.
169
+ function toTime(v) {
170
+ if (v == null) return NaN;
171
+ if (v instanceof Date) return v.getTime();
172
+ if (typeof v === 'number') return v;
173
+ const t = Date.parse(v);
174
+ return isNaN(t) ? Number(v) : t;
175
+ }
176
+ function chartLocale() {
177
+ try { return Alpine.store('locale')?.current || document.documentElement.lang || 'en'; } catch (_) { return 'en'; }
178
+ }
179
+
119
180
  function configFromDom(el) {
120
181
  // Declarative authoring: <figure x-chart.line><data series="Revenue" :values="..."></data></figure>
121
182
  const cfg = { series: [], labels: [] };
@@ -150,10 +211,13 @@
150
211
  cfg.gap = num(cfg.gap, 1); // heatmap tile gutter in px
151
212
  cfg.labels = Array.isArray(cfg.labels) ? cfg.labels : [];
152
213
 
153
- // Series may be omitted in favor of a single `data` array.
154
- if (!cfg.series && cfg.data) cfg.series = [{ name: cfg.name || '', data: cfg.data }];
214
+ // Series may be omitted in favor of a single `data` array. (A map's
215
+ // `data` is a region→value object, left untouched for drawMap.)
216
+ if (!cfg.series && Array.isArray(cfg.data) && cfg.type !== 'map') cfg.series = [{ name: cfg.name || '', data: cfg.data }];
155
217
  if (!Array.isArray(cfg.series)) cfg.series = [];
156
218
 
219
+ if (cfg.type === 'map') cfg.map = cfg.map || 'world';
220
+
157
221
  // Pie/donut: accept [{label,value}] in data.
158
222
  if ((cfg.type === 'pie' || cfg.type === 'donut') && cfg.series.length) {
159
223
  const s = cfg.series[0];
@@ -163,6 +227,14 @@
163
227
  }
164
228
  }
165
229
 
230
+ // Gantt: series are tracks of time/numeric/category segments.
231
+ // (`timeline` accepted as a legacy alias.)
232
+ if (cfg.type === 'gantt' || cfg.type === 'timeline') {
233
+ cfg.type = 'gantt';
234
+ cfg.rowHeight = num(cfg.rowHeight, 28);
235
+ cfg.markers = Array.isArray(cfg.markers) ? cfg.markers : [];
236
+ }
237
+
166
238
  // Gauge: a single value, from `value` or the first series datum.
167
239
  if (cfg.type === 'gauge') {
168
240
  if (cfg.value != null && !cfg.series.length) cfg.series = [{ data: [num(cfg.value, 0)] }];
@@ -285,7 +357,28 @@
285
357
  const cfg = state.config; const d3 = state.d3; const el = state.el;
286
358
  if (!cfg || !d3) return;
287
359
  const width = Math.max(120, el.clientWidth || el.getBoundingClientRect().width || 600);
288
- const height = cfg.height;
360
+
361
+ // Maps have their own data shape, async geo deps, and aspect-based
362
+ // height — handled before the cartesian/series path.
363
+ if (cfg.type === 'map') {
364
+ el.innerHTML = ''; probePalette(el);
365
+ const geo = window.__manifestGeo;
366
+ if (!geo) {
367
+ el.style.minHeight = (cfg.height || 320) + 'px';
368
+ loadGeo().then(() => state.schedule()).catch(() => state.renderError('Map data failed to load.'));
369
+ return;
370
+ }
371
+ _suppressAnim = !!state._drawn; state._drawn = true;
372
+ const mh = cfg.height || Math.round(width * 0.52);
373
+ el.style.minHeight = mh + 'px';
374
+ const mroot = svg('svg', { viewBox: `0 0 ${width} ${mh}`, width: '100%', height: String(mh), role: 'img', 'aria-label': cfg.title || 'map', preserveAspectRatio: 'xMidYMid meet' }, el);
375
+ drawMap(state, mroot, width, mh, geo);
376
+ return;
377
+ }
378
+
379
+ // Gantt sizes by track count, not a fixed height.
380
+ const height = cfg.type === 'gantt' ? ganttHeight(cfg) : cfg.height;
381
+ if (cfg.type === 'gantt') el.style.minHeight = height + 'px';
289
382
 
290
383
  el.innerHTML = '';
291
384
  probePalette(el);
@@ -304,9 +397,16 @@
304
397
  if (cfg.type === 'pie' || cfg.type === 'donut') drawPie(state, root, width, height);
305
398
  else if (cfg.type === 'gauge') drawGauge(state, root, width, height);
306
399
  else if (cfg.type === 'heatmap') drawHeatmap(state, root, width, height);
400
+ else if (cfg.type === 'gantt') drawGantt(state, root, width, height);
307
401
  else drawCartesian(state, root, width, height);
308
402
  }
309
403
 
404
+ // Gantt height derives from track count (one lane per series) + axis.
405
+ function ganttHeight(cfg) {
406
+ const n = Math.max(1, (cfg.series || []).length);
407
+ return 4 + n * cfg.rowHeight + 22;
408
+ }
409
+
310
410
  // Palette size is CSS-driven: count consecutive --color-chart-N custom
311
411
  // properties (themes can extend past 8 by defining --color-chart-9, …);
312
412
  // segment colours cycle through however many exist. Re-probed per draw
@@ -671,6 +771,261 @@
671
771
  state.el.appendChild(footer);
672
772
  }
673
773
 
774
+ // Gantt — each series is a track (row); its data is an array
775
+ // of `{ from, to, status?, label?, color? }` segments on a shared X axis.
776
+ // The axis adapts to the data: numbers → linear (0–10, distance, %),
777
+ // date-ish values → time, other strings → category (ordinal stages). A
778
+ // segment with no `to` is a point marker. Single track = a status strip;
779
+ // many tracks = swimlanes / Gantt.
780
+ function drawGantt(state, root, width, height) {
781
+ const cfg = state.config, d3 = state.d3;
782
+ const tracks = cfg.series;
783
+ const trackName = (t, i) => t.name || String(i + 1);
784
+ const showLabels = cfg.axis;
785
+ const unit = cfg.unit || '';
786
+ const m = { top: 4, right: 10, bottom: 22, left: showLabels ? 92 : 8 };
787
+ const iw = width - m.left - m.right;
788
+ const ih = height - m.top - m.bottom;
789
+
790
+ // Axis kind from the first segment's `from`.
791
+ let firstFrom;
792
+ for (const t of tracks) { if (t.data && t.data.length) { firstFrom = t.data[0].from; break; } }
793
+ const mode = (firstFrom instanceof Date) ? 'time'
794
+ : typeof firstFrom === 'number' ? 'numeric'
795
+ : (!isNaN(Date.parse(firstFrom)) ? 'time' : 'category');
796
+
797
+ let x, ticks, fmt, pos;
798
+ if (mode === 'category') {
799
+ const seen = [];
800
+ tracks.forEach(t => (t.data || []).forEach(s => [s.from, s.to].forEach(v => { if (v != null && seen.indexOf(String(v)) < 0) seen.push(String(v)); })));
801
+ const domain = (cfg.labels && cfg.labels.length) ? cfg.labels.map(String) : seen;
802
+ x = d3.scalePoint().domain(domain).range([0, iw]);
803
+ pos = v => x(String(v));
804
+ ticks = domain;
805
+ fmt = v => String(v);
806
+ } else {
807
+ const conv = mode === 'time' ? toTime : (v => num(v, 0));
808
+ let dmin = Infinity, dmax = -Infinity;
809
+ tracks.forEach(t => (t.data || []).forEach(s => {
810
+ const a = conv(s.from), b = conv(s.to != null ? s.to : s.from);
811
+ if (a < dmin) dmin = a; if (b > dmax) dmax = b;
812
+ }));
813
+ if (cfg.min != null) dmin = conv(cfg.min);
814
+ if (cfg.max != null) dmax = conv(cfg.max);
815
+ if (!isFinite(dmin) || !isFinite(dmax) || dmin === dmax) { dmin = 0; dmax = 1; }
816
+ x = (mode === 'time' ? d3.scaleTime() : d3.scaleLinear()).domain([dmin, dmax]).range([0, iw]);
817
+ pos = v => x(conv(v));
818
+ ticks = x.ticks(Math.max(2, Math.floor(iw / 80)));
819
+ fmt = mode === 'time' ? ganttFmt(dmin, dmax) : (v => String(v) + unit);
820
+ }
821
+ const tipVal = v => mode === 'time' ? fmt(toTime(v)) : String(v) + (mode === 'numeric' ? unit : '');
822
+
823
+ const lane = ih / Math.max(1, tracks.length);
824
+ const pad = Math.min(6, lane * 0.18);
825
+ const plot = svg('g', { transform: `translate(${m.left},${m.top})` }, root);
826
+
827
+ // Grid lines + axis ticks (shared, at the bottom).
828
+ ticks.forEach(tk => {
829
+ const xx = pos(tk);
830
+ if (xx == null || isNaN(xx)) return;
831
+ if (cfg.grid) svg('line', { x1: xx, x2: xx, y1: 0, y2: ih }, plot);
832
+ text(plot, fmt(tk), { x: xx, y: ih + 14, 'text-anchor': 'middle' });
833
+ });
834
+
835
+ // Status → colour, stable across tracks. A per-segment `color` wins;
836
+ // else the config `colors` map; else the palette in first-seen order.
837
+ const statusColors = {}; let next = 0;
838
+ const colorFor = (s) => {
839
+ if (s.color) return s.color;
840
+ if (s.status == null) return seriesColorVar(0);
841
+ if (!(s.status in statusColors)) statusColors[s.status] = (cfg.colors && cfg.colors[s.status]) || seriesColorVar(next++);
842
+ return statusColors[s.status];
843
+ };
844
+
845
+ tracks.forEach((t, ti) => {
846
+ const y0 = ti * lane, by = y0 + pad, bh = Math.max(0, lane - pad * 2);
847
+ if (showLabels) text(plot, trackName(t, ti), { class: 'gantt-track', x: -8, y: y0 + lane / 2, 'text-anchor': 'end', 'dominant-baseline': 'central' });
848
+ (t.data || []).forEach(s => {
849
+ const x1 = pos(s.from);
850
+ if (x1 == null || isNaN(x1)) return;
851
+ const x2 = s.to != null ? pos(s.to) : x1;
852
+ const color = colorFor(s);
853
+ const label = s.status != null ? s.status : (s.label || '');
854
+ if (!(x2 > x1)) {
855
+ const pt = svg('rect', { class: 'gantt-point', x: x1 - 1.5, y: by, width: 3, height: bh, style: `--color-chart-color:${color}` }, plot);
856
+ applyTip(pt, (t.name ? t.name + ' · ' : '') + label + ' · ' + tipVal(s.from), cfg);
857
+ return;
858
+ }
859
+ const seg = svg('rect', { class: 'gantt-segment', x: x1, y: by, width: Math.max(1, x2 - x1), height: bh, style: `--color-chart-color:${color}` }, plot);
860
+ applyTip(seg, (t.name ? t.name + ' · ' : '') + label + ' · ' + tipVal(s.from) + ' – ' + tipVal(s.to), cfg);
861
+ animate(seg, [{ opacity: 0 }, { opacity: 1 }], { duration: 300, delay: ti * 30 });
862
+ if (cfg.dataLabels && label && (x2 - x1) > 26) dataLabel(plot, label, (x1 + x2) / 2, y0 + lane / 2, 'middle', 'central', 'inverse');
863
+ });
864
+ });
865
+
866
+ // Reference markers (e.g. a "now" line).
867
+ cfg.markers.forEach(mk => {
868
+ const xx = pos(mk.at);
869
+ if (!(xx >= 0 && xx <= iw)) return;
870
+ svg('line', { class: 'gantt-marker', x1: xx, x2: xx, y1: 0, y2: ih, style: mk.color ? `--color-chart-color:${mk.color}` : '' }, plot);
871
+ if (mk.label) text(plot, mk.label, { class: 'gantt-marker-label', x: xx, y: -1, 'text-anchor': 'middle' });
872
+ });
873
+
874
+ // Status legend (categorical), when more than one is present.
875
+ const statuses = Object.keys(statusColors);
876
+ if (cfg.legend && statuses.length > 1) drawSwatchLegend(state, statuses.map(name => ({ name, color: statusColors[name] })));
877
+ }
878
+
879
+ // Axis/tooltip date formatter sized to the visible span.
880
+ function ganttFmt(dmin, dmax) {
881
+ const DAY = 86400000, span = dmax - dmin;
882
+ const opts = span <= 2 * DAY ? { hour: '2-digit', minute: '2-digit' }
883
+ : span <= 120 * DAY ? { month: 'short', day: 'numeric' }
884
+ : { month: 'short', year: 'numeric' };
885
+ let f; try { f = new Intl.DateTimeFormat(chartLocale(), opts); } catch (_) { f = null; }
886
+ return v => f ? f.format(new Date(v)) : new Date(v).toISOString().slice(0, 16);
887
+ }
888
+
889
+ /* ---- World map (choropleth) --------------------------------- */
890
+ // Resolve an author key to country numeric id(s): numeric ISO, alpha-2,
891
+ // alpha-3, continent (name or code → all members), or country name (atlas
892
+ // names + a few common aliases). Returns [] when unresolved.
893
+ const _mapAlias = { 'united states': 'united states of america', usa: 'united states of america', 'u.s.': 'united states of america', uk: 'united kingdom', 'great britain': 'united kingdom', russia: 'russia', czechia: 'czechia', 'czech republic': 'czechia', 'south korea': 'south korea', 'north korea': 'north korea', 'dr congo': 'dem. rep. congo', 'democratic republic of the congo': 'dem. rep. congo', tanzania: 'tanzania', bolivia: 'bolivia', laos: 'laos' };
894
+ const _contAlias = { 'n. america': 'north america', 'n america': 'north america', 's. america': 'south america', 's america': 'south america' };
895
+ let _nameIdx = null;
896
+ function nameIndex(geo) {
897
+ if (_nameIdx) return _nameIdx;
898
+ _nameIdx = {};
899
+ geo.world.objects.countries.geometries.forEach(g => { if (g.properties && g.properties.name) _nameIdx[String(g.properties.name).toLowerCase()] = Number(g.id); });
900
+ Object.keys(_mapAlias).forEach(a => { const id = _nameIdx[_mapAlias[a]]; if (id != null) _nameIdx[a] = id; });
901
+ return _nameIdx;
902
+ }
903
+ function resolveRegion(key, geo) {
904
+ const k = String(key).trim(); if (!k) return { kind: '', ids: [] };
905
+ if (/^\d+$/.test(k)) return { kind: 'country', ids: [Number(k)] };
906
+ const meta = geo.meta || {}, up = k.toUpperCase(), low = k.toLowerCase();
907
+ if (k.length === 2 && meta.a2 && meta.a2[up] != null) return { kind: 'country', ids: [meta.a2[up]] };
908
+ if (k.length === 3 && meta.a3 && meta.a3[up] != null) return { kind: 'country', ids: [meta.a3[up]] };
909
+ const members = meta.members || {};
910
+ const cont = Object.keys(members).find(c => c.toLowerCase() === (_contAlias[low] || low));
911
+ if (cont) return { kind: 'continent', code: cont, ids: members[cont] };
912
+ const id = nameIndex(geo)[low];
913
+ return id != null ? { kind: 'country', ids: [id] } : { kind: '', ids: [] };
914
+ }
915
+
916
+ // Choropleth: country (or continent-grouped) regions coloured sequentially
917
+ // (numeric values → heat ramp) or categorically (string values → palette).
918
+ function drawMap(state, root, width, height, geo) {
919
+ const cfg = state.config;
920
+ const fc = geo.topojson.feature(geo.world, geo.world.objects.countries);
921
+ const projection = geo.geoNaturalEarth1().fitSize([width, height], fc);
922
+ const path = geo.geoPath(projection);
923
+
924
+ // Author data: an object { key: value } or an array of { id|code|name, value } / [key, value].
925
+ let entries = [];
926
+ if (Array.isArray(cfg.data)) entries = cfg.data.map(d => Array.isArray(d) ? [d[0], d[1]] : [d.id != null ? d.id : (d.code != null ? d.code : d.name), d.value]);
927
+ else if (cfg.data && typeof cfg.data === 'object') entries = Object.entries(cfg.data);
928
+
929
+ const valueById = {};
930
+ entries.forEach(([key, val]) => resolveRegion(key, geo).ids.forEach(id => { valueById[id] = val; }));
931
+
932
+ // Display-name resolution: `_ui.map.regions` override → localized
933
+ // pack (per $locale) → English atlas name. Async packs trigger a
934
+ // redraw when they arrive.
935
+ const num2a2 = (geo.meta && geo.meta.num2a2) || {};
936
+ const locale = chartLocale();
937
+ const ui = (window.ManifestUI && window.ManifestUI.resolve) ? window.ManifestUI.resolve('map', {}) : {};
938
+ const overrides = ui.regions || {};
939
+ let pack = null;
940
+ if (locale && locale !== 'en') { if (_namePacks[locale]) pack = _namePacks[locale]; else loadCountryNames(locale).then(() => state.schedule()); }
941
+ const displayName = (id, eng) => {
942
+ const a2 = num2a2[id];
943
+ if (a2 != null && overrides[a2] != null) return overrides[a2];
944
+ if (overrides[id] != null) return overrides[id];
945
+ if (overrides[String(id)] != null) return overrides[String(id)];
946
+ if (overrides[eng] != null) return overrides[eng];
947
+ if (pack && a2 && pack[a2]) return pack[a2];
948
+ return eng;
949
+ };
950
+
951
+ // Author-supplied place gazetteer (config `places` or `_ui.map.cities`)
952
+ // for resolving point names → coords. No city data is bundled; authors
953
+ // source it from a third-party library in their own project.
954
+ const placesSrc = cfg.places || ui.cities || null;
955
+ let places = null;
956
+ if (placesSrc) { places = {}; Object.keys(placesSrc).forEach(k => { places[k.toLowerCase()] = placesSrc[k]; }); }
957
+
958
+ // Sequential when every value is numeric, else categorical.
959
+ const vals = entries.map(e => e[1]);
960
+ const numeric = vals.length > 0 && vals.every(v => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && !isNaN(+v)));
961
+ let lo = Infinity, hi = -Infinity;
962
+ if (numeric) { Object.values(valueById).forEach(v => { const n = +v; if (n < lo) lo = n; if (n > hi) hi = n; }); if (!isFinite(lo)) { lo = 0; hi = 1; } if (lo === hi) hi = lo + 1; }
963
+ const palette = {}; let pIdx = 0;
964
+
965
+ const plot = svg('g', {}, root);
966
+ fc.features.forEach(f => {
967
+ const id = Number(f.id), v = valueById[id];
968
+ const region = svg('path', { class: 'map-region', d: path(f) }, plot);
969
+ const name = displayName(id, (f.properties && f.properties.name) || String(id));
970
+ if (v != null && v !== '') {
971
+ if (numeric) { const t = Math.round((+v - lo) / (hi - lo) * 100); region.setAttribute('style', `--color-chart-color:color-mix(in oklch, var(--color-chart-heat-high) ${t}%, var(--color-chart-heat-low))`); }
972
+ else { if (!(v in palette)) palette[v] = seriesColorVar(pIdx++); region.setAttribute('style', `--color-chart-color:${palette[v]}`); }
973
+ applyTip(region, name + ': ' + v, cfg);
974
+ animate(region, [{ opacity: 0 }, { opacity: 1 }], { duration: 250 });
975
+ } else {
976
+ applyTip(region, name, cfg);
977
+ }
978
+ });
979
+
980
+ // City / point markers (proportional symbols) — projected through the
981
+ // same projection. `points: [{ lat, lng, value?, label?, color? }]`.
982
+ const pts = Array.isArray(cfg.points) ? cfg.points : [];
983
+ if (pts.length) {
984
+ const coord = (p) => {
985
+ let lat = p.lat != null ? p.lat : p.latitude;
986
+ let lng = p.lng != null ? p.lng : (p.lon != null ? p.lon : (p.long != null ? p.long : p.longitude));
987
+ if ((lat == null || lng == null) && places) {
988
+ const hit = places[String(p.city || p.name || '').toLowerCase()];
989
+ if (hit) { if (Array.isArray(hit)) { lat = hit[0]; lng = hit[1]; } else { lat = hit.lat; lng = hit.lng; } }
990
+ }
991
+ return (lat == null || lng == null) ? null : [+lng, +lat];
992
+ };
993
+ const pv = pts.map(p => p.value);
994
+ const sized = pv.length > 0 && pv.every(v => typeof v === 'number');
995
+ let pmin = Infinity, pmax = -Infinity;
996
+ if (sized) { pv.forEach(v => { if (v < pmin) pmin = v; if (v > pmax) pmax = v; }); if (!isFinite(pmin)) { pmin = 0; pmax = 1; } if (pmin === pmax) pmax = pmin + 1; }
997
+ const rOf = (v) => sized ? 3 + Math.sqrt((+v - pmin) / (pmax - pmin)) * 13 : 4;
998
+ pts.forEach(p => {
999
+ const c = coord(p), xy = c && projection(c);
1000
+ if (!xy) return;
1001
+ const label = p.label || p.city || p.name || '';
1002
+ const dot = svg('circle', { class: 'map-point', cx: xy[0], cy: xy[1], r: rOf(p.value), style: p.color ? `--color-chart-color:${p.color}` : '' }, plot);
1003
+ applyTip(dot, label + (p.value != null ? (label ? ': ' : '') + p.value : ''), cfg);
1004
+ animate(dot, [{ opacity: 0 }, { opacity: 1 }], { duration: 250 });
1005
+ });
1006
+ }
1007
+
1008
+ if (cfg.legend) {
1009
+ if (numeric) drawHeatLegend(state, Math.round(lo), Math.round(hi), { left: 8, right: 8 });
1010
+ else if (entries.length) { const items = Object.keys(palette).map(name => ({ name, color: palette[name] })); if (items.length) drawSwatchLegend(state, items); }
1011
+ }
1012
+ }
1013
+
1014
+ // Categorical swatch legend from an explicit [{ name, color }] list
1015
+ // (gantt statuses carry their own colour, unlike series legends).
1016
+ function drawSwatchLegend(state, items) {
1017
+ const footer = document.createElement('footer');
1018
+ items.forEach(it => {
1019
+ const item = document.createElement('span');
1020
+ const sw = document.createElement('i');
1021
+ sw.style.setProperty('--color-chart-color', it.color);
1022
+ const tx = document.createElement('span');
1023
+ tx.textContent = it.name;
1024
+ item.append(sw, tx); footer.appendChild(item);
1025
+ });
1026
+ state.el.appendChild(footer);
1027
+ }
1028
+
674
1029
  // Legend is a <footer> sibling below the SVG (inline flex), not an
675
1030
  // absolute overlay — so it never collides with axis labels. Each item is
676
1031
  // a <span> with an <i> swatch carrying the series colour.
@@ -2,7 +2,7 @@
2
2
 
3
3
  @layer components {
4
4
 
5
- /* Field shell — inherits input tokens so it reads as a normal field */
5
+ /* Wrapper */
6
6
  :where(.combobox):not(.unstyle) {
7
7
  display: flex;
8
8
  flex-wrap: wrap;
@@ -40,16 +40,8 @@
40
40
  pointer-events: none
41
41
  }
42
42
 
43
- /* Inner typing surface. The :not(.unstyle) lifts specificity above
44
- manifest.input.css, which sorts after this file in the bundled CSS and
45
- would otherwise re-apply its own width/background/hover to the editor. */
43
+ /* Inner typing surface */
46
44
  &> :where(input:not([type=hidden]), textarea):not(.unstyle) {
47
- /* flex-basis (not min-width) sets the wrap threshold: the editor fills
48
- the rest of the row, and once less than 7rem is left it wraps to its
49
- own line. min-width:0 is REQUIRED — a flex item's default min-width:auto
50
- is its content size, and WebKit enforces it by crushing the
51
- flex-shrink:0 chips to an ellipsis. Resetting it to 0 lets the editor
52
- shrink/wrap instead, so the chips keep their room. */
53
45
  flex: 1 1 7rem;
54
46
  min-width: 0;
55
47
  width: auto;
@@ -75,7 +67,7 @@
75
67
  /* Chips */
76
68
  :where(.combobox-chip):not(.unstyle) {
77
69
  display: inline-flex;
78
- flex: 0 0 auto; /* hold content width; don't grow or shrink — overflow:hidden below otherwise lets flex squeeze chips to an ellipsis (notably Safari) */
70
+ flex: 0 0 auto;
79
71
  align-items: center;
80
72
  gap: var(--spacing, 0.25rem);
81
73
  max-width: 100%;
@@ -117,7 +109,7 @@
117
109
  }
118
110
  }
119
111
 
120
- /* Locked — selected but not removable; no × button, so restore the right padding */
112
+ /* Non-removable */
121
113
  &[data-locked] {
122
114
  padding-inline-end: calc(var(--spacing, 0.25rem) * 2);
123
115
  cursor: default
@@ -130,9 +122,8 @@
130
122
  }
131
123
  }
132
124
 
133
- /* Trigger (editor:none button mode) — transparent; the shell is the field.
134
- Fills the row like the input editor, with a select-style caret. */
135
- :where(.combobox) > button:not(.unstyle) {
125
+ /* Button trigger */
126
+ :where(.combobox)>button:not(.unstyle) {
136
127
  flex: 1 1 auto;
137
128
  align-self: stretch;
138
129
  min-width: 7rem;
@@ -161,10 +152,7 @@
161
152
  }
162
153
  }
163
154
 
164
- /* Chip-less field (single input, textarea, or button trigger): the editor IS
165
- the field — it fills the whole wrapper and carries the padding, so the click
166
- target, caret and text selection align with the field edges instead of
167
- floating inside the wrapper's padding. */
155
+ /* Chip-less */
168
156
  :where(.combobox):has(> :where(input:not([type=hidden]), textarea, button):not(.unstyle)):not(:has(.combobox-chip)) {
169
157
  padding: 0;
170
158
 
@@ -175,15 +163,10 @@
175
163
  }
176
164
  }
177
165
 
178
- /* Listbox — base look comes from menu[popover]; these are combobox-only extras.
179
- Selectors deliberately AVOID :where() on the option part so they out-specify
180
- dropdown.css's `menu li { display: inline-flex }` (0,1,0), which sorts after
181
- this file in the bundle and would otherwise keep filtered options visible. */
166
+ /* Listbox */
182
167
  :where(menu[role=listbox]):not(.unstyle) {
183
168
 
184
- /* Active descendant — only shown while the keyboard is driving (data-kbd).
185
- On a mouse-opened menu the hover state alone highlights, so the first
186
- option isn't left with a persistent background. */
169
+ /* Active descendant */
187
170
  &[data-kbd] [role=option][aria-current="true"] {
188
171
  color: var(--color-field-inverse, oklch(43.9% 0 0));
189
172
  background-color: var(--color-field-surface, color-mix(in oklch, oklch(20.5% 0 0) 10%, transparent))