graphein 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -741,11 +741,33 @@ var CATEGORICAL = {
741
741
  "#f97316",
742
742
  "#6366f1",
743
743
  "#84cc16"
744
+ ],
745
+ colorblind: ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7", "#999999"],
746
+ bright: [
747
+ "#2563eb",
748
+ "#06b6d4",
749
+ "#22c55e",
750
+ "#eab308",
751
+ "#f97316",
752
+ "#ef4444",
753
+ "#a855f7",
754
+ "#ec4899"
755
+ ],
756
+ muted: [
757
+ "#4f46e5",
758
+ "#0f766e",
759
+ "#65a30d",
760
+ "#ca8a04",
761
+ "#c2410c",
762
+ "#be123c",
763
+ "#7c3aed",
764
+ "#475569"
744
765
  ]
745
766
  };
746
767
  function categorical(name = "graphein") {
747
768
  return [...CATEGORICAL[name] ?? CATEGORICAL.graphein];
748
769
  }
770
+ var categoricalSchemes = Object.keys(CATEGORICAL);
749
771
  var must = (hex) => {
750
772
  const c = parseColor(hex);
751
773
  if (!c) throw new Error(`Graphein: invalid palette color ${hex}`);
@@ -1841,9 +1863,9 @@ function summarizeChart(spec) {
1841
1863
  return { label: description.trim(), rowCount };
1842
1864
  }
1843
1865
  const type = chartTypeLabel(spec.type);
1844
- const title = chartTitleText(spec) ?? (spec.type === "kpi" ? spec.label : void 0);
1866
+ const title2 = chartTitleText(spec) ?? (spec.type === "kpi" ? spec.label : void 0);
1845
1867
  let label = type;
1846
- if (title) label += `: ${title}`;
1868
+ if (title2) label += `: ${title2}`;
1847
1869
  if (rowCount > 0 && spec.type !== "kpi") {
1848
1870
  label += `. ${rowCount} data point${rowCount === 1 ? "" : "s"}.`;
1849
1871
  }
@@ -3071,1484 +3093,998 @@ function linearRegression(xs, ys) {
3071
3093
  };
3072
3094
  }
3073
3095
 
3074
- // src/render/env.ts
3075
- var isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
3076
- function getDevicePixelRatio() {
3077
- const dpr = globalThis.devicePixelRatio;
3078
- return typeof dpr === "number" && dpr > 0 ? dpr : 1;
3079
- }
3096
+ // src/spec/types.ts
3097
+ var SLICER_TYPES = [
3098
+ "dropdown",
3099
+ "search",
3100
+ "list",
3101
+ "range",
3102
+ "dateRange"
3103
+ ];
3104
+ var CHART_TYPES = [
3105
+ "line",
3106
+ "area",
3107
+ "bar",
3108
+ "scatter",
3109
+ "combo",
3110
+ "histogram",
3111
+ "pie",
3112
+ "heatmap",
3113
+ "funnel",
3114
+ "kpi",
3115
+ "treemap",
3116
+ "gauge",
3117
+ "bullet",
3118
+ "calendarHeatmap",
3119
+ "waterfall",
3120
+ "slope",
3121
+ "dumbbell",
3122
+ "table",
3123
+ "matrix",
3124
+ "box",
3125
+ "sankey",
3126
+ "choropleth",
3127
+ "dropdown",
3128
+ "search",
3129
+ "list",
3130
+ "range",
3131
+ "dateRange"
3132
+ ];
3080
3133
 
3081
- // src/render/sizing.ts
3082
- function computeBackingSize(width, height, dpr) {
3083
- const d = dpr > 0 ? dpr : 1;
3084
- const cssWidth = Math.max(0, width);
3085
- const cssHeight = Math.max(0, height);
3086
- return {
3087
- cssWidth,
3088
- cssHeight,
3089
- pixelWidth: Math.max(1, Math.round(cssWidth * d)),
3090
- pixelHeight: Math.max(1, Math.round(cssHeight * d)),
3091
- dpr: d
3134
+ // src/spec/transform/types.ts
3135
+ var TRANSFORM_KINDS = ["aggregate", "bin", "filter", "fold", "timeUnit", "calculate"];
3136
+ var TIME_UNITS = [
3137
+ "year",
3138
+ "quarter",
3139
+ "month",
3140
+ "week",
3141
+ "day",
3142
+ "hour",
3143
+ "minute",
3144
+ "second"
3145
+ ];
3146
+
3147
+ // src/spec/transform/aggregate.ts
3148
+ function applyAggregate(transform2, data) {
3149
+ const groupby = transform2.groupby ?? [];
3150
+ const reads = groupby.map((f) => accessor(f));
3151
+ const emit = (keyValues, rows) => {
3152
+ const out2 = {};
3153
+ groupby.forEach((field, i) => {
3154
+ out2[field] = keyValues[i];
3155
+ });
3156
+ for (const agg of transform2.aggregate) {
3157
+ const values = agg.field === void 0 ? rows : rows.map((r) => r[agg.field]);
3158
+ out2[agg.as] = aggregateValues(values, agg.op);
3159
+ }
3160
+ return out2;
3092
3161
  };
3162
+ if (groupby.length === 0) {
3163
+ return [emit([], data)];
3164
+ }
3165
+ const groups = /* @__PURE__ */ new Map();
3166
+ for (const row of data) {
3167
+ const keyValues = reads.map((read) => read(row));
3168
+ const key = keyValues.map((v) => toKey(v)).join("\0");
3169
+ let group = groups.get(key);
3170
+ if (group === void 0) {
3171
+ group = { keys: keyValues, rows: [] };
3172
+ groups.set(key, group);
3173
+ }
3174
+ group.rows.push(row);
3175
+ }
3176
+ const out = [];
3177
+ for (const group of groups.values()) {
3178
+ out.push(emit(group.keys, group.rows));
3179
+ }
3180
+ return out;
3093
3181
  }
3094
3182
 
3095
- // src/render/dom.ts
3096
- function setStyle(el, style) {
3097
- Object.assign(el.style, style);
3098
- }
3099
- function createDiv(className, style) {
3100
- const el = document.createElement("div");
3101
- if (className) el.className = className;
3102
- if (style) setStyle(el, style);
3103
- return el;
3183
+ // src/spec/transform/bin.ts
3184
+ function roundNice(value, step) {
3185
+ if (!Number.isFinite(step) || step === 0) return value;
3186
+ const places = Math.max(0, -Math.floor(Math.log10(Math.abs(step))) + 1);
3187
+ const rounded = Number(value.toFixed(Math.min(places, 15)));
3188
+ return Object.is(rounded, -0) ? 0 : rounded;
3104
3189
  }
3105
- var fillParentStyle = {
3106
- position: "absolute",
3107
- top: "0",
3108
- left: "0"
3109
- };
3110
-
3111
- // src/render/canvasLayer.ts
3112
- var CanvasLayer = class {
3113
- constructor(canvas) {
3114
- __publicField(this, "canvas");
3115
- __publicField(this, "ctx");
3116
- /** Logical (CSS px) width. */
3117
- __publicField(this, "width", 0);
3118
- /** Logical (CSS px) height. */
3119
- __publicField(this, "height", 0);
3120
- __publicField(this, "dpr", 1);
3121
- this.canvas = canvas ?? document.createElement("canvas");
3122
- const ctx = this.canvas.getContext("2d");
3123
- if (!ctx) {
3124
- throw new Error("Graphein: 2D canvas context is unavailable in this environment.");
3190
+ function computeBins(values, opts = {}) {
3191
+ let min = Infinity;
3192
+ let max = -Infinity;
3193
+ if (opts.extent) {
3194
+ [min, max] = opts.extent;
3195
+ } else {
3196
+ for (const v of values) {
3197
+ if (!Number.isFinite(v)) continue;
3198
+ if (v < min) min = v;
3199
+ if (v > max) max = v;
3125
3200
  }
3126
- this.ctx = ctx;
3127
3201
  }
3128
- /** Resize the layer to a CSS size; updates the backing store + DPR transform. */
3129
- resize(width, height, dpr = getDevicePixelRatio()) {
3130
- const b = computeBackingSize(width, height, dpr);
3131
- this.width = b.cssWidth;
3132
- this.height = b.cssHeight;
3133
- this.dpr = b.dpr;
3134
- if (this.canvas.width !== b.pixelWidth) this.canvas.width = b.pixelWidth;
3135
- if (this.canvas.height !== b.pixelHeight) this.canvas.height = b.pixelHeight;
3136
- this.canvas.style.width = `${b.cssWidth}px`;
3137
- this.canvas.style.height = `${b.cssHeight}px`;
3138
- this.ctx.setTransform(b.dpr, 0, 0, b.dpr, 0, 0);
3202
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
3203
+ const explicit = opts.step !== void 0 && opts.step > 0;
3204
+ if (min === max) {
3205
+ const step2 = explicit ? opts.step : 1;
3206
+ return { start: min, step: step2, count: 1 };
3139
3207
  }
3140
- /** Clear the entire backing store regardless of the current transform. */
3141
- clear() {
3142
- const { ctx, canvas } = this;
3143
- ctx.save();
3144
- ctx.setTransform(1, 0, 0, 1, 0, 0);
3145
- ctx.clearRect(0, 0, canvas.width, canvas.height);
3146
- ctx.restore();
3208
+ const nice = opts.nice !== false;
3209
+ let step = explicit ? opts.step : tickStep(min, max, opts.maxbins ?? 10);
3210
+ if (!Number.isFinite(step) || step <= 0) step = max - min || 1;
3211
+ const start = nice ? Math.floor(min / step) * step : min;
3212
+ const end = nice ? Math.ceil(max / step) * step : max;
3213
+ const count = Math.max(1, Math.ceil((end - start) / step - 1e-9));
3214
+ return { start: roundNice(start, step), step, count };
3215
+ }
3216
+ function applyBin(transform2, data) {
3217
+ const read = accessor(transform2.bin);
3218
+ const finite4 = [];
3219
+ for (const row of data) {
3220
+ const n = toNumber(read(row));
3221
+ if (Number.isFinite(n)) finite4.push(n);
3222
+ }
3223
+ const layout2 = computeBins(finite4, {
3224
+ maxbins: transform2.maxbins,
3225
+ step: transform2.step,
3226
+ extent: transform2.extent,
3227
+ nice: transform2.nice
3228
+ });
3229
+ const startField = Array.isArray(transform2.as) ? transform2.as[0] : transform2.as;
3230
+ const endField = Array.isArray(transform2.as) ? transform2.as[1] : void 0;
3231
+ return data.map((row) => {
3232
+ const out = { ...row };
3233
+ const v = toNumber(read(row));
3234
+ const outside = transform2.extent != null && (v < transform2.extent[0] || v > transform2.extent[1]);
3235
+ if (layout2 == null || !Number.isFinite(v) || outside) {
3236
+ out[startField] = null;
3237
+ if (endField) out[endField] = null;
3238
+ return out;
3239
+ }
3240
+ let index = Math.floor((v - layout2.start) / layout2.step + 1e-9);
3241
+ if (index < 0) index = 0;
3242
+ if (index > layout2.count - 1) index = layout2.count - 1;
3243
+ out[startField] = roundNice(layout2.start + index * layout2.step, layout2.step);
3244
+ if (endField) out[endField] = roundNice(layout2.start + (index + 1) * layout2.step, layout2.step);
3245
+ return out;
3246
+ });
3247
+ }
3248
+
3249
+ // src/spec/transform/expr/error.ts
3250
+ var ExpressionError = class extends Error {
3251
+ constructor(message, pos) {
3252
+ super(message);
3253
+ /** Source offset where the problem was detected, when known. */
3254
+ __publicField(this, "pos");
3255
+ this.name = "ExpressionError";
3256
+ this.pos = pos;
3147
3257
  }
3148
3258
  };
3149
3259
 
3150
- // src/render/surface.ts
3151
- var Surface = class {
3152
- constructor(container) {
3153
- __publicField(this, "container");
3154
- __publicField(this, "root");
3155
- __publicField(this, "marks");
3156
- __publicField(this, "interaction");
3157
- __publicField(this, "overlay");
3158
- /** Visually-hidden container for the screen-reader data-table fallback. */
3159
- __publicField(this, "a11y");
3160
- __publicField(this, "width", 0);
3161
- __publicField(this, "height", 0);
3162
- __publicField(this, "dpr", 1);
3163
- /**
3164
- * When true, overlay text (axis labels, titles, legends, annotation labels) is
3165
- * painted onto the `marks` canvas instead of the HTML overlay. Set by headless
3166
- * renderers (`@graphein/node`) where there is no DOM to host crisp text.
3167
- * Defaults to false (browser).
3168
- */
3169
- __publicField(this, "headless", false);
3170
- this.container = container;
3171
- this.root = createDiv("graphein-root", {
3172
- position: "relative",
3173
- width: "100%",
3174
- height: "100%",
3175
- overflow: "hidden"
3176
- });
3177
- const marksCanvas = document.createElement("canvas");
3178
- marksCanvas.className = "graphein-layer-marks";
3179
- setStyle(marksCanvas, { ...fillParentStyle });
3180
- const interactionCanvas = document.createElement("canvas");
3181
- interactionCanvas.className = "graphein-layer-interaction";
3182
- setStyle(interactionCanvas, { ...fillParentStyle, pointerEvents: "none" });
3183
- this.overlay = createDiv("graphein-layer-overlay", {
3184
- ...fillParentStyle,
3185
- width: "100%",
3186
- height: "100%",
3187
- pointerEvents: "none"
3188
- });
3189
- this.a11y = createDiv("graphein-a11y", {
3190
- position: "absolute",
3191
- width: "1px",
3192
- height: "1px",
3193
- overflow: "hidden",
3194
- clip: "rect(0 0 0 0)",
3195
- clipPath: "inset(50%)",
3196
- whiteSpace: "nowrap",
3197
- border: "0",
3198
- margin: "-1px",
3199
- padding: "0"
3200
- });
3201
- this.root.appendChild(marksCanvas);
3202
- this.root.appendChild(interactionCanvas);
3203
- this.root.appendChild(this.overlay);
3204
- this.root.appendChild(this.a11y);
3205
- container.appendChild(this.root);
3206
- this.marks = new CanvasLayer(marksCanvas);
3207
- this.interaction = new CanvasLayer(interactionCanvas);
3260
+ // src/spec/transform/expr/tokenize.ts
3261
+ var MULTI_OPS = ["===", "!==", "==", "!=", "<=", ">=", "&&", "||"];
3262
+ var SINGLE_OPS = new Set("+-*/%!<>?:.,()[]".split(""));
3263
+ function isDigit(ch) {
3264
+ return ch >= "0" && ch <= "9";
3265
+ }
3266
+ function isIdentStart(ch) {
3267
+ return ch >= "a" && ch <= "z" || ch >= "A" && ch <= "Z" || ch === "_" || ch === "$";
3268
+ }
3269
+ function isIdentPart(ch) {
3270
+ return isIdentStart(ch) || isDigit(ch);
3271
+ }
3272
+ function tokenize(src) {
3273
+ const tokens = [];
3274
+ let i = 0;
3275
+ const n = src.length;
3276
+ while (i < n) {
3277
+ const ch = src[i];
3278
+ if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
3279
+ i++;
3280
+ continue;
3281
+ }
3282
+ if (ch === '"' || ch === "'") {
3283
+ const start = i;
3284
+ const quote = ch;
3285
+ i++;
3286
+ let out = "";
3287
+ while (i < n && src[i] !== quote) {
3288
+ if (src[i] === "\\") {
3289
+ const next = src[i + 1];
3290
+ if (next === void 0) throw new ExpressionError("Unterminated string literal.", start);
3291
+ out += next === "n" ? "\n" : next === "t" ? " " : next === "r" ? "\r" : next;
3292
+ i += 2;
3293
+ } else {
3294
+ out += src[i];
3295
+ i++;
3296
+ }
3297
+ }
3298
+ if (i >= n) throw new ExpressionError("Unterminated string literal.", start);
3299
+ i++;
3300
+ tokens.push({ type: "str", value: out, pos: start });
3301
+ continue;
3302
+ }
3303
+ if (isDigit(ch) || ch === "." && isDigit(src[i + 1] ?? "")) {
3304
+ const start = i;
3305
+ while (i < n && isDigit(src[i])) i++;
3306
+ if (src[i] === ".") {
3307
+ i++;
3308
+ while (i < n && isDigit(src[i])) i++;
3309
+ }
3310
+ if (src[i] === "e" || src[i] === "E") {
3311
+ i++;
3312
+ if (src[i] === "+" || src[i] === "-") i++;
3313
+ if (!isDigit(src[i] ?? "")) throw new ExpressionError("Malformed number literal.", start);
3314
+ while (i < n && isDigit(src[i])) i++;
3315
+ }
3316
+ tokens.push({ type: "num", value: Number(src.slice(start, i)), pos: start });
3317
+ continue;
3318
+ }
3319
+ if (isIdentStart(ch)) {
3320
+ const start = i;
3321
+ i++;
3322
+ while (i < n && isIdentPart(src[i])) i++;
3323
+ tokens.push({ type: "ident", value: src.slice(start, i), pos: start });
3324
+ continue;
3325
+ }
3326
+ const three = src.slice(i, i + 3);
3327
+ const two = src.slice(i, i + 2);
3328
+ const multi = MULTI_OPS.find((op) => op === three) ?? MULTI_OPS.find((op) => op === two);
3329
+ if (multi) {
3330
+ tokens.push({ type: "op", value: multi, pos: i });
3331
+ i += multi.length;
3332
+ continue;
3333
+ }
3334
+ if (SINGLE_OPS.has(ch)) {
3335
+ tokens.push({ type: "op", value: ch, pos: i });
3336
+ i++;
3337
+ continue;
3338
+ }
3339
+ throw new ExpressionError(`Unexpected character ${JSON.stringify(ch)}.`, i);
3208
3340
  }
3209
- resize(width, height, dpr = getDevicePixelRatio()) {
3210
- this.width = width;
3211
- this.height = height;
3212
- this.dpr = dpr;
3213
- this.marks.resize(width, height, dpr);
3214
- this.interaction.resize(width, height, dpr);
3215
- setStyle(this.overlay, { width: `${width}px`, height: `${height}px` });
3341
+ tokens.push({ type: "eof", value: "", pos: n });
3342
+ return tokens;
3343
+ }
3344
+
3345
+ // src/spec/transform/expr/parse.ts
3346
+ var EQUALITY = /* @__PURE__ */ new Set(["==", "!=", "===", "!=="]);
3347
+ var RELATIONAL = /* @__PURE__ */ new Set(["<", "<=", ">", ">="]);
3348
+ var ADDITIVE = /* @__PURE__ */ new Set(["+", "-"]);
3349
+ var MULTIPLICATIVE = /* @__PURE__ */ new Set(["*", "/", "%"]);
3350
+ var Parser = class {
3351
+ constructor(tokens) {
3352
+ __publicField(this, "tokens");
3353
+ __publicField(this, "pos", 0);
3354
+ this.tokens = tokens;
3216
3355
  }
3217
- /** Clear both canvas layers and empty the HTML overlay. */
3218
- clear() {
3219
- this.marks.clear();
3220
- this.interaction.clear();
3221
- this.overlay.replaceChildren();
3356
+ peek() {
3357
+ return this.tokens[this.pos];
3222
3358
  }
3223
- /** Remove all DOM created by this Surface. */
3224
- destroy() {
3225
- this.clear();
3226
- this.root.remove();
3359
+ next() {
3360
+ return this.tokens[this.pos++];
3227
3361
  }
3228
- };
3229
-
3230
- // src/render/overlayText.ts
3231
- function paintCanvasText(ctx, c) {
3232
- const align = c.align ?? "left";
3233
- const baseline = c.baseline ?? "top";
3234
- ctx.save();
3235
- if (c.opacity != null) ctx.globalAlpha *= c.opacity;
3236
- if (c.rotate) {
3237
- ctx.translate(c.x, c.y);
3238
- ctx.rotate(c.rotate);
3239
- ctx.translate(-c.x, -c.y);
3362
+ isOp(value) {
3363
+ const t = this.peek();
3364
+ return t.type === "op" && t.value === value;
3240
3365
  }
3241
- ctx.font = c.font;
3242
- ctx.textAlign = align;
3243
- ctx.textBaseline = baseline;
3244
- if (c.pill) {
3245
- const padX = c.pill.padX ?? 7;
3246
- const padY = c.pill.padY ?? 2;
3247
- const tw = ctx.measureText(c.text).width;
3248
- const th = c.size;
3249
- let bx = c.x;
3250
- if (align === "center") bx = c.x - tw / 2;
3251
- else if (align === "right") bx = c.x - tw;
3252
- let by = c.y;
3253
- if (baseline === "middle") by = c.y - th / 2;
3254
- else if (baseline === "alphabetic") by = c.y - th;
3255
- ctx.beginPath();
3256
- roundedRect(ctx, bx - padX, by - padY, tw + padX * 2, th + padY * 2, c.pill.radius ?? 999);
3257
- ctx.fillStyle = c.pill.background;
3258
- ctx.fill();
3259
- if (c.pill.border) {
3260
- ctx.lineWidth = 1;
3261
- ctx.strokeStyle = c.pill.border;
3262
- ctx.stroke();
3366
+ eat(value) {
3367
+ if (!this.isOp(value)) {
3368
+ const t = this.peek();
3369
+ throw new ExpressionError(`Expected "${value}" but found ${describe(t)}.`, t.pos);
3263
3370
  }
3371
+ this.pos++;
3264
3372
  }
3265
- ctx.fillStyle = c.color;
3266
- ctx.fillText(c.text, c.x, c.y);
3267
- ctx.restore();
3268
- }
3269
- function overlayTextToCanvasCmd(o, font) {
3270
- const t = o.transform ?? "";
3271
- const rotate = t.includes("rotate(-90deg)") ? -Math.PI / 2 : t.includes("rotate(90deg)") ? Math.PI / 2 : (() => {
3272
- const m = t.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
3273
- return m ? parseFloat(m[1]) * Math.PI / 180 : void 0;
3274
- })();
3275
- let align = "left";
3276
- let x = o.left;
3277
- const y = o.top;
3278
- if (o.width != null) {
3279
- align = o.align ?? "left";
3280
- x = align === "right" ? o.left + o.width : align === "center" ? o.left + o.width / 2 : o.left;
3281
- } else if (t.includes("translateX(-100%)")) {
3282
- align = "right";
3283
- } else if (t.includes("translateX(-50%)") || t.includes("translate(-50%")) {
3284
- align = "center";
3373
+ parse() {
3374
+ const node = this.parseTernary();
3375
+ const t = this.peek();
3376
+ if (t.type !== "eof") throw new ExpressionError(`Unexpected ${describe(t)}.`, t.pos);
3377
+ return node;
3285
3378
  }
3286
- const baseline = t.includes("translateY(-50%)") || t.includes(",-50%") || t.includes(", -50%") ? "middle" : "top";
3287
- return {
3288
- x,
3289
- y,
3290
- text: o.text,
3291
- font,
3292
- color: o.color,
3293
- size: o.size,
3294
- align,
3295
- baseline,
3296
- rotate,
3297
- opacity: o.opacity,
3298
- pill: o.pill
3299
- };
3300
- }
3301
- function legendSwatchWidth(symbol, swatch = 11) {
3302
- return symbol === "line" ? swatch + 3 : swatch;
3303
- }
3304
- function paintLegendSwatch(ctx, x, midY, symbol, color, swatch = 11) {
3305
- ctx.save();
3306
- ctx.fillStyle = color;
3307
- ctx.beginPath();
3308
- if (symbol === "circle") {
3309
- ctx.arc(x + swatch / 2, midY, swatch / 2, 0, Math.PI * 2);
3310
- } else if (symbol === "line") {
3311
- roundedRect(ctx, x, midY - 1.5, swatch + 3, 3, 1.5);
3312
- } else {
3313
- roundedRect(ctx, x, midY - swatch / 2, swatch, swatch, 2);
3379
+ parseTernary() {
3380
+ const test = this.parseBinary(0);
3381
+ if (this.isOp("?")) {
3382
+ this.eat("?");
3383
+ const then = this.parseTernary();
3384
+ this.eat(":");
3385
+ const alt = this.parseTernary();
3386
+ return { type: "ternary", test, then, alt };
3387
+ }
3388
+ return test;
3314
3389
  }
3315
- ctx.fill();
3316
- ctx.restore();
3317
- }
3318
-
3319
- // src/render/text.ts
3320
- var measureCtx;
3321
- var injectedCtx = null;
3322
- function setMeasureContext(ctx) {
3323
- injectedCtx = ctx;
3324
- }
3325
- function getMeasureCtx() {
3326
- if (injectedCtx) return injectedCtx;
3327
- if (measureCtx !== void 0) return measureCtx;
3328
- try {
3329
- measureCtx = document.createElement("canvas").getContext("2d");
3330
- } catch {
3331
- measureCtx = null;
3390
+ /** Precedence-climbing for the binary/logical operator levels. */
3391
+ parseBinary(minLevel) {
3392
+ let left = this.parseUnary();
3393
+ for (; ; ) {
3394
+ const t = this.peek();
3395
+ if (t.type !== "op") break;
3396
+ const level = binaryLevel(t.value);
3397
+ if (level < minLevel || level < 0) break;
3398
+ this.next();
3399
+ const right = this.parseBinary(level + 1);
3400
+ const op = t.value;
3401
+ left = op === "&&" || op === "||" ? { type: "logical", op, left, right } : { type: "binary", op, left, right };
3402
+ }
3403
+ return left;
3332
3404
  }
3333
- return measureCtx;
3334
- }
3335
- function measureText(text, font) {
3336
- const ctx = getMeasureCtx();
3337
- if (!ctx) {
3338
- const sizeMatch = /(\d+(?:\.\d+)?)px/.exec(font);
3339
- const size = sizeMatch ? parseFloat(sizeMatch[1]) : 12;
3340
- return { width: text.length * size * 0.55 };
3405
+ parseUnary() {
3406
+ const t = this.peek();
3407
+ if (t.type === "op" && (t.value === "!" || t.value === "-" || t.value === "+")) {
3408
+ this.next();
3409
+ return { type: "unary", op: t.value, arg: this.parseUnary() };
3410
+ }
3411
+ return this.parsePostfix();
3341
3412
  }
3342
- ctx.font = font;
3343
- return { width: ctx.measureText(text).width };
3344
- }
3345
- function fontString(size, family, weight = 400, style = "normal") {
3346
- return `${style} ${weight} ${size}px ${family}`;
3347
- }
3348
- function ellipsize(text, maxWidth, font) {
3349
- if (maxWidth <= 0 || text.length === 0) return "";
3350
- if (measureText(text, font).width <= maxWidth) return text;
3351
- const ellipsis = "\u2026";
3352
- const ellipsisW = measureText(ellipsis, font).width;
3353
- if (ellipsisW > maxWidth) return "";
3354
- let lo = 0;
3355
- let hi = text.length;
3356
- while (lo < hi) {
3357
- const mid = Math.ceil((lo + hi) / 2);
3358
- if (measureText(text.slice(0, mid) + ellipsis, font).width <= maxWidth) lo = mid;
3359
- else hi = mid - 1;
3413
+ parsePostfix() {
3414
+ let node = this.parsePrimary();
3415
+ for (; ; ) {
3416
+ if (this.isOp(".")) {
3417
+ this.eat(".");
3418
+ const t = this.next();
3419
+ if (t.type !== "ident") throw new ExpressionError(`Expected a property name after ".".`, t.pos);
3420
+ node = { type: "member", obj: node, prop: { type: "str", value: t.value }, computed: false };
3421
+ } else if (this.isOp("[")) {
3422
+ this.eat("[");
3423
+ const prop = this.parseTernary();
3424
+ this.eat("]");
3425
+ node = { type: "member", obj: node, prop, computed: true };
3426
+ } else {
3427
+ break;
3428
+ }
3429
+ }
3430
+ return node;
3360
3431
  }
3361
- return text.slice(0, lo) + ellipsis;
3362
- }
3363
-
3364
- // src/theme/tokens.ts
3365
- var PALETTE = [
3366
- "#3b82f6",
3367
- // blue
3368
- "#14b8a6",
3369
- // teal
3370
- "#f59e0b",
3371
- // amber
3372
- "#ef4444",
3373
- // red
3374
- "#8b5cf6",
3375
- // violet
3376
- "#ec4899",
3377
- // pink
3378
- "#10b981",
3379
- // emerald
3380
- "#f97316",
3381
- // orange
3382
- "#6366f1",
3383
- // indigo
3384
- "#84cc16"
3385
- // lime
3386
- ];
3387
- var FONT_FAMILY = 'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
3388
- var SHARED = {
3389
- font: {
3390
- family: FONT_FAMILY,
3391
- size: { tiny: 10, small: 11, base: 12, large: 14, title: 16 },
3392
- weight: { normal: 400, medium: 500, bold: 600 }
3393
- },
3394
- spacing: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 },
3395
- radius: { sm: 4, md: 6, lg: 8 },
3396
- stroke: { thin: 1, base: 1.5, thick: 2 }
3397
- };
3398
- var lightTheme = {
3399
- name: "light",
3400
- dark: false,
3401
- color: {
3402
- background: "#ffffff",
3403
- surface: "#f8fafc",
3404
- text: "#0f172a",
3405
- textMuted: "#64748b",
3406
- axis: "#cbd5e1",
3407
- grid: "#eef2f6",
3408
- border: "#e2e8f0",
3409
- accent: "#0d9488",
3410
- palette: PALETTE,
3411
- positive: "#16a34a",
3412
- negative: "#dc2626"
3413
- },
3414
- ...SHARED
3415
- };
3416
- var darkTheme = {
3417
- name: "dark",
3418
- dark: true,
3419
- color: {
3420
- background: "#0b1220",
3421
- surface: "#111a2e",
3422
- text: "#f1f5f9",
3423
- textMuted: "#94a3b8",
3424
- axis: "#334155",
3425
- grid: "#1c2740",
3426
- border: "#1e293b",
3427
- accent: "#2dd4bf",
3428
- palette: PALETTE,
3429
- positive: "#22c55e",
3430
- negative: "#f87171"
3431
- },
3432
- ...SHARED
3433
- };
3434
- var themes = {
3435
- light: lightTheme,
3436
- dark: darkTheme
3437
- };
3438
- function isPlainObject(v) {
3439
- return typeof v === "object" && v !== null && !Array.isArray(v);
3440
- }
3441
- function deepMerge(base, override) {
3442
- if (!isPlainObject(override)) return base;
3443
- if (!isPlainObject(base)) return override;
3444
- const out = { ...base };
3445
- for (const [key, value] of Object.entries(override)) {
3446
- const baseValue = out[key];
3447
- if (isPlainObject(baseValue) && isPlainObject(value)) {
3448
- out[key] = deepMerge(baseValue, value);
3449
- } else {
3450
- out[key] = value;
3432
+ parsePrimary() {
3433
+ const t = this.next();
3434
+ if (t.type === "num") return { type: "num", value: t.value };
3435
+ if (t.type === "str") return { type: "str", value: t.value };
3436
+ if (t.type === "ident") {
3437
+ const name = t.value;
3438
+ if (name === "true") return { type: "lit", value: true };
3439
+ if (name === "false") return { type: "lit", value: false };
3440
+ if (name === "null") return { type: "lit", value: null };
3441
+ if (this.isOp("(")) {
3442
+ this.eat("(");
3443
+ const args = [];
3444
+ if (!this.isOp(")")) {
3445
+ args.push(this.parseTernary());
3446
+ while (this.isOp(",")) {
3447
+ this.eat(",");
3448
+ args.push(this.parseTernary());
3449
+ }
3450
+ }
3451
+ this.eat(")");
3452
+ return { type: "call", callee: name, args };
3453
+ }
3454
+ if (name === "datum") return { type: "datum" };
3455
+ return { type: "field", name };
3451
3456
  }
3457
+ if (t.type === "op" && t.value === "(") {
3458
+ const node = this.parseTernary();
3459
+ this.eat(")");
3460
+ return node;
3461
+ }
3462
+ throw new ExpressionError(`Unexpected ${describe(t)}.`, t.pos);
3452
3463
  }
3453
- return out;
3464
+ };
3465
+ function binaryLevel(op) {
3466
+ if (op === "||") return 1;
3467
+ if (op === "&&") return 2;
3468
+ if (EQUALITY.has(op)) return 3;
3469
+ if (RELATIONAL.has(op)) return 4;
3470
+ if (ADDITIVE.has(op)) return 5;
3471
+ if (MULTIPLICATIVE.has(op)) return 6;
3472
+ return -1;
3454
3473
  }
3455
- function resolveTheme(theme) {
3456
- if (theme == null) return lightTheme;
3457
- if (typeof theme === "string") return themes[theme] ?? lightTheme;
3458
- const baseName = theme.base ?? (theme.dark ? "dark" : "light");
3459
- const base = themes[baseName] ?? lightTheme;
3460
- const { base: _ignored, ...override } = theme;
3461
- return deepMerge(base, override);
3474
+ function describe(t) {
3475
+ if (t.type === "eof") return "end of input";
3476
+ if (t.type === "str") return `string ${JSON.stringify(t.value)}`;
3477
+ if (t.type === "num") return `number ${t.value}`;
3478
+ return `"${t.value}"`;
3479
+ }
3480
+ function parse(src) {
3481
+ if (typeof src !== "string" || src.trim() === "") {
3482
+ throw new ExpressionError("Expression must be a non-empty string.", 0);
3483
+ }
3484
+ return new Parser(tokenize(src)).parse();
3462
3485
  }
3463
3486
 
3464
- // src/theme/sketch.ts
3465
- var SKETCH_FONT_NAME = "Patrick Hand";
3466
- var SKETCH_FONT_FAMILY = `'Patrick Hand', 'Segoe Print', 'Bradley Hand', 'Comic Sans MS', ui-rounded, cursive`;
3467
- var injected = false;
3468
- function injectViaStyle(dataUrl) {
3469
- const style = document.createElement("style");
3470
- style.setAttribute("data-graphein-sketch-font", "");
3471
- style.textContent = `@font-face{font-family:'${SKETCH_FONT_NAME}';font-style:normal;font-weight:400;font-display:swap;src:url("${dataUrl}") format("woff2");}`;
3472
- document.head.appendChild(style);
3487
+ // src/spec/transform/expr/evaluate.ts
3488
+ var BLOCKED_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
3489
+ function truthy(v) {
3490
+ if (typeof v === "number") return v !== 0 && !Number.isNaN(v);
3491
+ return Boolean(v);
3473
3492
  }
3474
- async function ensureSketchFont() {
3475
- if (injected) return false;
3476
- if (typeof document === "undefined") return false;
3477
- injected = true;
3478
- try {
3479
- const { PATRICK_HAND_DATA_URL } = await import('./patrickHand-ITDQJJE2.js');
3480
- const docFonts = document.fonts;
3481
- if (typeof FontFace !== "undefined" && docFonts?.add) {
3482
- const face = new FontFace(
3483
- SKETCH_FONT_NAME,
3484
- `url("${PATRICK_HAND_DATA_URL}") format("woff2")`,
3485
- { style: "normal", weight: "400" }
3486
- );
3487
- await face.load();
3488
- docFonts.add(face);
3489
- return true;
3490
- }
3491
- injectViaStyle(PATRICK_HAND_DATA_URL);
3492
- return true;
3493
- } catch {
3494
- injected = false;
3495
- return false;
3496
- }
3493
+ function toStr(v) {
3494
+ if (v == null) return "";
3495
+ if (v instanceof Date) return v.toISOString();
3496
+ return String(v);
3497
3497
  }
3498
- function withSketchFont(tokens) {
3499
- return {
3500
- ...tokens,
3501
- font: {
3502
- ...tokens.font,
3503
- family: `${SKETCH_FONT_FAMILY}`
3504
- }
3505
- };
3498
+ function looseEquals(a, b) {
3499
+ if (a == null || b == null) return a == null && b == null;
3500
+ const na = toNumber(a);
3501
+ const nb = toNumber(b);
3502
+ if (Number.isFinite(na) && Number.isFinite(nb)) return na === nb;
3503
+ return toStr(a) === toStr(b);
3506
3504
  }
3507
-
3508
- // src/spec/types.ts
3509
- var SLICER_TYPES = [
3510
- "dropdown",
3511
- "search",
3512
- "list",
3513
- "range",
3514
- "dateRange"
3515
- ];
3516
- var CHART_TYPES = [
3517
- "line",
3518
- "area",
3519
- "bar",
3520
- "scatter",
3521
- "combo",
3522
- "histogram",
3523
- "pie",
3524
- "heatmap",
3525
- "funnel",
3526
- "kpi",
3527
- "treemap",
3528
- "gauge",
3529
- "bullet",
3530
- "calendarHeatmap",
3531
- "waterfall",
3532
- "slope",
3533
- "dumbbell",
3534
- "table",
3535
- "matrix",
3536
- "box",
3537
- "sankey",
3538
- "choropleth",
3539
- "dropdown",
3540
- "search",
3541
- "list",
3542
- "range",
3543
- "dateRange"
3544
- ];
3545
-
3546
- // src/spec/transform/types.ts
3547
- var TRANSFORM_KINDS = ["aggregate", "bin", "filter", "fold", "timeUnit", "calculate"];
3548
- var TIME_UNITS = [
3549
- "year",
3550
- "quarter",
3551
- "month",
3552
- "week",
3553
- "day",
3554
- "hour",
3555
- "minute",
3556
- "second"
3557
- ];
3558
-
3559
- // src/spec/transform/aggregate.ts
3560
- function applyAggregate(transform2, data) {
3561
- const groupby = transform2.groupby ?? [];
3562
- const reads = groupby.map((f) => accessor(f));
3563
- const emit = (keyValues, rows) => {
3564
- const out2 = {};
3565
- groupby.forEach((field, i) => {
3566
- out2[field] = keyValues[i];
3567
- });
3568
- for (const agg of transform2.aggregate) {
3569
- const values = agg.field === void 0 ? rows : rows.map((r) => r[agg.field]);
3570
- out2[agg.as] = aggregateValues(values, agg.op);
3505
+ function compare(a, b) {
3506
+ const na = toNumber(a);
3507
+ const nb = toNumber(b);
3508
+ if (Number.isFinite(na) && Number.isFinite(nb)) return na < nb ? -1 : na > nb ? 1 : 0;
3509
+ const sa = toStr(a);
3510
+ const sb = toStr(b);
3511
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
3512
+ }
3513
+ var nums = (args) => args.map(toNumber);
3514
+ var FUNCTIONS = {
3515
+ abs: (a) => Math.abs(toNumber(a[0])),
3516
+ round: (a) => {
3517
+ const d = a.length > 1 ? Math.trunc(toNumber(a[1])) : 0;
3518
+ const f = 10 ** d;
3519
+ return Math.round(toNumber(a[0]) * f) / f;
3520
+ },
3521
+ floor: (a) => Math.floor(toNumber(a[0])),
3522
+ ceil: (a) => Math.ceil(toNumber(a[0])),
3523
+ trunc: (a) => Math.trunc(toNumber(a[0])),
3524
+ sign: (a) => Math.sign(toNumber(a[0])),
3525
+ sqrt: (a) => Math.sqrt(toNumber(a[0])),
3526
+ exp: (a) => Math.exp(toNumber(a[0])),
3527
+ log: (a) => Math.log(toNumber(a[0])),
3528
+ log10: (a) => Math.log10(toNumber(a[0])),
3529
+ log2: (a) => Math.log2(toNumber(a[0])),
3530
+ pow: (a) => toNumber(a[0]) ** toNumber(a[1]),
3531
+ min: (a) => Math.min(...nums(a)),
3532
+ max: (a) => Math.max(...nums(a)),
3533
+ number: (a) => toNumber(a[0]),
3534
+ isFinite: (a) => Number.isFinite(toNumber(a[0])),
3535
+ isNaN: (a) => Number.isNaN(toNumber(a[0])),
3536
+ str: (a) => toStr(a[0]),
3537
+ lower: (a) => toStr(a[0]).toLowerCase(),
3538
+ upper: (a) => toStr(a[0]).toUpperCase(),
3539
+ trim: (a) => toStr(a[0]).trim(),
3540
+ length: (a) => Array.isArray(a[0]) ? a[0].length : toStr(a[0]).length,
3541
+ substring: (a) => {
3542
+ const s = toStr(a[0]);
3543
+ const start = Math.trunc(toNumber(a[1]));
3544
+ return a.length > 2 ? s.substring(start, Math.trunc(toNumber(a[2]))) : s.substring(start);
3545
+ },
3546
+ replace: (a) => toStr(a[0]).split(toStr(a[1])).join(toStr(a[2])),
3547
+ contains: (a) => toStr(a[0]).includes(toStr(a[1])),
3548
+ startsWith: (a) => toStr(a[0]).startsWith(toStr(a[1])),
3549
+ endsWith: (a) => toStr(a[0]).endsWith(toStr(a[1])),
3550
+ concat: (a) => a.map(toStr).join(""),
3551
+ coalesce: (a) => a.find((v) => v != null),
3552
+ if: (a) => truthy(a[0]) ? a[1] : a[2],
3553
+ year: (a) => toDate(a[0])?.getFullYear() ?? NaN,
3554
+ month: (a) => {
3555
+ const d = toDate(a[0]);
3556
+ return d ? d.getMonth() + 1 : NaN;
3557
+ },
3558
+ day: (a) => toDate(a[0])?.getDate() ?? NaN,
3559
+ hours: (a) => toDate(a[0])?.getHours() ?? NaN,
3560
+ minutes: (a) => toDate(a[0])?.getMinutes() ?? NaN
3561
+ };
3562
+ var FUNCTION_NAMES = Object.keys(FUNCTIONS);
3563
+ function evalNode(node, row) {
3564
+ switch (node.type) {
3565
+ case "num":
3566
+ case "str":
3567
+ case "lit":
3568
+ return node.value;
3569
+ case "datum":
3570
+ return row;
3571
+ case "field":
3572
+ return row[node.name];
3573
+ case "unary": {
3574
+ const v = evalNode(node.arg, row);
3575
+ if (node.op === "!") return !truthy(v);
3576
+ if (node.op === "-") return -toNumber(v);
3577
+ return toNumber(v);
3571
3578
  }
3572
- return out2;
3573
- };
3574
- if (groupby.length === 0) {
3575
- return [emit([], data)];
3576
- }
3577
- const groups = /* @__PURE__ */ new Map();
3578
- for (const row of data) {
3579
- const keyValues = reads.map((read) => read(row));
3580
- const key = keyValues.map((v) => toKey(v)).join("\0");
3581
- let group = groups.get(key);
3582
- if (group === void 0) {
3583
- group = { keys: keyValues, rows: [] };
3584
- groups.set(key, group);
3579
+ case "logical": {
3580
+ const left = evalNode(node.left, row);
3581
+ if (node.op === "&&") return truthy(left) ? evalNode(node.right, row) : left;
3582
+ return truthy(left) ? left : evalNode(node.right, row);
3583
+ }
3584
+ case "ternary":
3585
+ return truthy(evalNode(node.test, row)) ? evalNode(node.then, row) : evalNode(node.alt, row);
3586
+ case "binary":
3587
+ return evalBinary(node.op, evalNode(node.left, row), evalNode(node.right, row));
3588
+ case "member": {
3589
+ const obj = evalNode(node.obj, row);
3590
+ if (obj == null || typeof obj !== "object") return void 0;
3591
+ const key = node.computed ? toStr(evalNode(node.prop, row)) : node.prop.value;
3592
+ if (BLOCKED_KEYS.has(key)) return void 0;
3593
+ return obj[key];
3594
+ }
3595
+ case "call": {
3596
+ const fn = Object.prototype.hasOwnProperty.call(FUNCTIONS, node.callee) ? FUNCTIONS[node.callee] : void 0;
3597
+ if (!fn) throw new ExpressionError(`Unknown function "${node.callee}".`);
3598
+ return fn(node.args.map((a) => evalNode(a, row)));
3585
3599
  }
3586
- group.rows.push(row);
3587
3600
  }
3588
- const out = [];
3589
- for (const group of groups.values()) {
3590
- out.push(emit(group.keys, group.rows));
3601
+ }
3602
+ function evalBinary(op, a, b) {
3603
+ switch (op) {
3604
+ case "+":
3605
+ return typeof a === "string" || typeof b === "string" ? toStr(a) + toStr(b) : toNumber(a) + toNumber(b);
3606
+ case "-":
3607
+ return toNumber(a) - toNumber(b);
3608
+ case "*":
3609
+ return toNumber(a) * toNumber(b);
3610
+ case "/":
3611
+ return toNumber(a) / toNumber(b);
3612
+ case "%":
3613
+ return toNumber(a) % toNumber(b);
3614
+ case "<":
3615
+ return compare(a, b) < 0;
3616
+ case "<=":
3617
+ return compare(a, b) <= 0;
3618
+ case ">":
3619
+ return compare(a, b) > 0;
3620
+ case ">=":
3621
+ return compare(a, b) >= 0;
3622
+ case "==":
3623
+ return looseEquals(a, b);
3624
+ case "!=":
3625
+ return !looseEquals(a, b);
3626
+ case "===":
3627
+ return a === b;
3628
+ case "!==":
3629
+ return a !== b;
3630
+ default:
3631
+ throw new ExpressionError(`Unknown operator "${op}".`);
3591
3632
  }
3592
- return out;
3633
+ }
3634
+ function evaluate(node, row) {
3635
+ return evalNode(node, row);
3593
3636
  }
3594
3637
 
3595
- // src/spec/transform/bin.ts
3596
- function roundNice(value, step) {
3597
- if (!Number.isFinite(step) || step === 0) return value;
3598
- const places = Math.max(0, -Math.floor(Math.log10(Math.abs(step))) + 1);
3599
- const rounded = Number(value.toFixed(Math.min(places, 15)));
3600
- return Object.is(rounded, -0) ? 0 : rounded;
3638
+ // src/spec/transform/expr/index.ts
3639
+ function compileExpression(src) {
3640
+ const ast = parse(src);
3641
+ return (row) => evaluate(ast, row);
3601
3642
  }
3602
- function computeBins(values, opts = {}) {
3603
- let min = Infinity;
3604
- let max = -Infinity;
3605
- if (opts.extent) {
3606
- [min, max] = opts.extent;
3607
- } else {
3608
- for (const v of values) {
3609
- if (!Number.isFinite(v)) continue;
3610
- if (v < min) min = v;
3611
- if (v > max) max = v;
3612
- }
3613
- }
3614
- if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
3615
- const explicit = opts.step !== void 0 && opts.step > 0;
3616
- if (min === max) {
3617
- const step2 = explicit ? opts.step : 1;
3618
- return { start: min, step: step2, count: 1 };
3643
+ function checkExpression(src) {
3644
+ try {
3645
+ parse(src);
3646
+ return null;
3647
+ } catch (e) {
3648
+ if (e instanceof ExpressionError) return { message: e.message, pos: e.pos };
3649
+ throw e;
3619
3650
  }
3620
- const nice = opts.nice !== false;
3621
- let step = explicit ? opts.step : tickStep(min, max, opts.maxbins ?? 10);
3622
- if (!Number.isFinite(step) || step <= 0) step = max - min || 1;
3623
- const start = nice ? Math.floor(min / step) * step : min;
3624
- const end = nice ? Math.ceil(max / step) * step : max;
3625
- const count = Math.max(1, Math.ceil((end - start) / step - 1e-9));
3626
- return { start: roundNice(start, step), step, count };
3627
3651
  }
3628
- function applyBin(transform2, data) {
3629
- const read = accessor(transform2.bin);
3630
- const finite4 = [];
3631
- for (const row of data) {
3632
- const n = toNumber(read(row));
3633
- if (Number.isFinite(n)) finite4.push(n);
3634
- }
3635
- const layout2 = computeBins(finite4, {
3636
- maxbins: transform2.maxbins,
3637
- step: transform2.step,
3638
- extent: transform2.extent,
3639
- nice: transform2.nice
3640
- });
3641
- const startField = Array.isArray(transform2.as) ? transform2.as[0] : transform2.as;
3642
- const endField = Array.isArray(transform2.as) ? transform2.as[1] : void 0;
3643
- return data.map((row) => {
3644
- const out = { ...row };
3645
- const v = toNumber(read(row));
3646
- const outside = transform2.extent != null && (v < transform2.extent[0] || v > transform2.extent[1]);
3647
- if (layout2 == null || !Number.isFinite(v) || outside) {
3648
- out[startField] = null;
3649
- if (endField) out[endField] = null;
3650
- return out;
3651
- }
3652
- let index = Math.floor((v - layout2.start) / layout2.step + 1e-9);
3653
- if (index < 0) index = 0;
3654
- if (index > layout2.count - 1) index = layout2.count - 1;
3655
- out[startField] = roundNice(layout2.start + index * layout2.step, layout2.step);
3656
- if (endField) out[endField] = roundNice(layout2.start + (index + 1) * layout2.step, layout2.step);
3657
- return out;
3658
- });
3659
- }
3660
-
3661
- // src/spec/transform/expr/error.ts
3662
- var ExpressionError = class extends Error {
3663
- constructor(message, pos) {
3664
- super(message);
3665
- /** Source offset where the problem was detected, when known. */
3666
- __publicField(this, "pos");
3667
- this.name = "ExpressionError";
3668
- this.pos = pos;
3669
- }
3670
- };
3671
3652
 
3672
- // src/spec/transform/expr/tokenize.ts
3673
- var MULTI_OPS = ["===", "!==", "==", "!=", "<=", ">=", "&&", "||"];
3674
- var SINGLE_OPS = new Set("+-*/%!<>?:.,()[]".split(""));
3675
- function isDigit(ch) {
3676
- return ch >= "0" && ch <= "9";
3677
- }
3678
- function isIdentStart(ch) {
3679
- return ch >= "a" && ch <= "z" || ch >= "A" && ch <= "Z" || ch === "_" || ch === "$";
3680
- }
3681
- function isIdentPart(ch) {
3682
- return isIdentStart(ch) || isDigit(ch);
3683
- }
3684
- function tokenize(src) {
3685
- const tokens = [];
3686
- let i = 0;
3687
- const n = src.length;
3688
- while (i < n) {
3689
- const ch = src[i];
3690
- if (ch === " " || ch === " " || ch === "\n" || ch === "\r") {
3691
- i++;
3692
- continue;
3693
- }
3694
- if (ch === '"' || ch === "'") {
3695
- const start = i;
3696
- const quote = ch;
3697
- i++;
3698
- let out = "";
3699
- while (i < n && src[i] !== quote) {
3700
- if (src[i] === "\\") {
3701
- const next = src[i + 1];
3702
- if (next === void 0) throw new ExpressionError("Unterminated string literal.", start);
3703
- out += next === "n" ? "\n" : next === "t" ? " " : next === "r" ? "\r" : next;
3704
- i += 2;
3705
- } else {
3706
- out += src[i];
3707
- i++;
3708
- }
3709
- }
3710
- if (i >= n) throw new ExpressionError("Unterminated string literal.", start);
3711
- i++;
3712
- tokens.push({ type: "str", value: out, pos: start });
3713
- continue;
3714
- }
3715
- if (isDigit(ch) || ch === "." && isDigit(src[i + 1] ?? "")) {
3716
- const start = i;
3717
- while (i < n && isDigit(src[i])) i++;
3718
- if (src[i] === ".") {
3719
- i++;
3720
- while (i < n && isDigit(src[i])) i++;
3721
- }
3722
- if (src[i] === "e" || src[i] === "E") {
3723
- i++;
3724
- if (src[i] === "+" || src[i] === "-") i++;
3725
- if (!isDigit(src[i] ?? "")) throw new ExpressionError("Malformed number literal.", start);
3726
- while (i < n && isDigit(src[i])) i++;
3727
- }
3728
- tokens.push({ type: "num", value: Number(src.slice(start, i)), pos: start });
3729
- continue;
3730
- }
3731
- if (isIdentStart(ch)) {
3732
- const start = i;
3733
- i++;
3734
- while (i < n && isIdentPart(src[i])) i++;
3735
- tokens.push({ type: "ident", value: src.slice(start, i), pos: start });
3736
- continue;
3737
- }
3738
- const three = src.slice(i, i + 3);
3739
- const two = src.slice(i, i + 2);
3740
- const multi = MULTI_OPS.find((op) => op === three) ?? MULTI_OPS.find((op) => op === two);
3741
- if (multi) {
3742
- tokens.push({ type: "op", value: multi, pos: i });
3743
- i += multi.length;
3744
- continue;
3745
- }
3746
- if (SINGLE_OPS.has(ch)) {
3747
- tokens.push({ type: "op", value: ch, pos: i });
3748
- i++;
3749
- continue;
3750
- }
3751
- throw new ExpressionError(`Unexpected character ${JSON.stringify(ch)}.`, i);
3752
- }
3753
- tokens.push({ type: "eof", value: "", pos: n });
3754
- return tokens;
3653
+ // src/spec/transform/calculate.ts
3654
+ function applyCalculate(transform2, data) {
3655
+ const evalRow = compileExpression(transform2.calculate);
3656
+ const as = transform2.as;
3657
+ return data.map((row) => ({ ...row, [as]: evalRow(row) }));
3755
3658
  }
3756
3659
 
3757
- // src/spec/transform/expr/parse.ts
3758
- var EQUALITY = /* @__PURE__ */ new Set(["==", "!=", "===", "!=="]);
3759
- var RELATIONAL = /* @__PURE__ */ new Set(["<", "<=", ">", ">="]);
3760
- var ADDITIVE = /* @__PURE__ */ new Set(["+", "-"]);
3761
- var MULTIPLICATIVE = /* @__PURE__ */ new Set(["*", "/", "%"]);
3762
- var Parser = class {
3763
- constructor(tokens) {
3764
- __publicField(this, "tokens");
3765
- __publicField(this, "pos", 0);
3766
- this.tokens = tokens;
3660
+ // src/spec/transform/filter.ts
3661
+ function toComparable(value) {
3662
+ if (typeof value === "number") return value;
3663
+ if (value instanceof Date) return value.getTime();
3664
+ const n = toNumber(value);
3665
+ if (!Number.isNaN(n)) return n;
3666
+ const d = toDate(value);
3667
+ return d ? d.getTime() : NaN;
3668
+ }
3669
+ function compilePredicate(pred) {
3670
+ if ("and" in pred) {
3671
+ const parts = pred.and.map(compilePredicate);
3672
+ return (row) => parts.every((p) => p(row));
3767
3673
  }
3768
- peek() {
3769
- return this.tokens[this.pos];
3674
+ if ("or" in pred) {
3675
+ const parts = pred.or.map(compilePredicate);
3676
+ return (row) => parts.some((p) => p(row));
3770
3677
  }
3771
- next() {
3772
- return this.tokens[this.pos++];
3678
+ if ("not" in pred) {
3679
+ const inner = compilePredicate(pred.not);
3680
+ return (row) => !inner(row);
3773
3681
  }
3774
- isOp(value) {
3775
- const t = this.peek();
3776
- return t.type === "op" && t.value === value;
3682
+ const read = accessor(pred.field);
3683
+ if ("equals" in pred) {
3684
+ const want = toKey(pred.equals);
3685
+ return (row) => toKey(read(row)) === want;
3777
3686
  }
3778
- eat(value) {
3779
- if (!this.isOp(value)) {
3780
- const t = this.peek();
3781
- throw new ExpressionError(`Expected "${value}" but found ${describe(t)}.`, t.pos);
3782
- }
3783
- this.pos++;
3687
+ if ("ne" in pred) {
3688
+ const want = toKey(pred.ne);
3689
+ return (row) => toKey(read(row)) !== want;
3784
3690
  }
3785
- parse() {
3786
- const node = this.parseTernary();
3787
- const t = this.peek();
3788
- if (t.type !== "eof") throw new ExpressionError(`Unexpected ${describe(t)}.`, t.pos);
3789
- return node;
3691
+ if ("oneOf" in pred) {
3692
+ const want = new Set(pred.oneOf.map((x) => toKey(x)));
3693
+ return (row) => want.has(toKey(read(row)));
3790
3694
  }
3791
- parseTernary() {
3792
- const test = this.parseBinary(0);
3793
- if (this.isOp("?")) {
3794
- this.eat("?");
3795
- const then = this.parseTernary();
3796
- this.eat(":");
3797
- const alt = this.parseTernary();
3798
- return { type: "ternary", test, then, alt };
3799
- }
3800
- return test;
3695
+ if ("contains" in pred) {
3696
+ const q = String(pred.contains).toLowerCase();
3697
+ return (row) => {
3698
+ const raw = read(row);
3699
+ return raw != null && String(raw).toLowerCase().includes(q);
3700
+ };
3801
3701
  }
3802
- /** Precedence-climbing for the binary/logical operator levels. */
3803
- parseBinary(minLevel) {
3804
- let left = this.parseUnary();
3805
- for (; ; ) {
3806
- const t = this.peek();
3807
- if (t.type !== "op") break;
3808
- const level = binaryLevel(t.value);
3809
- if (level < minLevel || level < 0) break;
3810
- this.next();
3811
- const right = this.parseBinary(level + 1);
3812
- const op = t.value;
3813
- left = op === "&&" || op === "||" ? { type: "logical", op, left, right } : { type: "binary", op, left, right };
3814
- }
3815
- return left;
3702
+ if ("valid" in pred) {
3703
+ const wantValid = pred.valid !== false;
3704
+ return (row) => {
3705
+ const raw = read(row);
3706
+ const ok = raw != null && !(typeof raw === "number" && Number.isNaN(raw));
3707
+ return ok === wantValid;
3708
+ };
3816
3709
  }
3817
- parseUnary() {
3818
- const t = this.peek();
3819
- if (t.type === "op" && (t.value === "!" || t.value === "-" || t.value === "+")) {
3820
- this.next();
3821
- return { type: "unary", op: t.value, arg: this.parseUnary() };
3822
- }
3823
- return this.parsePostfix();
3710
+ if ("range" in pred) {
3711
+ const a = toComparable(pred.range[0]);
3712
+ const b = toComparable(pred.range[1]);
3713
+ const min = Math.min(a, b);
3714
+ const max = Math.max(a, b);
3715
+ return (row) => {
3716
+ const c = toComparable(read(row));
3717
+ return !Number.isNaN(c) && c >= min && c <= max;
3718
+ };
3824
3719
  }
3825
- parsePostfix() {
3826
- let node = this.parsePrimary();
3827
- for (; ; ) {
3828
- if (this.isOp(".")) {
3829
- this.eat(".");
3830
- const t = this.next();
3831
- if (t.type !== "ident") throw new ExpressionError(`Expected a property name after ".".`, t.pos);
3832
- node = { type: "member", obj: node, prop: { type: "str", value: t.value }, computed: false };
3833
- } else if (this.isOp("[")) {
3834
- this.eat("[");
3835
- const prop = this.parseTernary();
3836
- this.eat("]");
3837
- node = { type: "member", obj: node, prop, computed: true };
3838
- } else {
3839
- break;
3840
- }
3841
- }
3842
- return node;
3720
+ if ("gt" in pred) {
3721
+ const t = toComparable(pred.gt);
3722
+ return (row) => {
3723
+ const c = toComparable(read(row));
3724
+ return !Number.isNaN(c) && c > t;
3725
+ };
3843
3726
  }
3844
- parsePrimary() {
3845
- const t = this.next();
3846
- if (t.type === "num") return { type: "num", value: t.value };
3847
- if (t.type === "str") return { type: "str", value: t.value };
3848
- if (t.type === "ident") {
3849
- const name = t.value;
3850
- if (name === "true") return { type: "lit", value: true };
3851
- if (name === "false") return { type: "lit", value: false };
3852
- if (name === "null") return { type: "lit", value: null };
3853
- if (this.isOp("(")) {
3854
- this.eat("(");
3855
- const args = [];
3856
- if (!this.isOp(")")) {
3857
- args.push(this.parseTernary());
3858
- while (this.isOp(",")) {
3859
- this.eat(",");
3860
- args.push(this.parseTernary());
3861
- }
3862
- }
3863
- this.eat(")");
3864
- return { type: "call", callee: name, args };
3865
- }
3866
- if (name === "datum") return { type: "datum" };
3867
- return { type: "field", name };
3868
- }
3869
- if (t.type === "op" && t.value === "(") {
3870
- const node = this.parseTernary();
3871
- this.eat(")");
3872
- return node;
3873
- }
3874
- throw new ExpressionError(`Unexpected ${describe(t)}.`, t.pos);
3727
+ if ("gte" in pred) {
3728
+ const t = toComparable(pred.gte);
3729
+ return (row) => {
3730
+ const c = toComparable(read(row));
3731
+ return !Number.isNaN(c) && c >= t;
3732
+ };
3875
3733
  }
3876
- };
3877
- function binaryLevel(op) {
3878
- if (op === "||") return 1;
3879
- if (op === "&&") return 2;
3880
- if (EQUALITY.has(op)) return 3;
3881
- if (RELATIONAL.has(op)) return 4;
3882
- if (ADDITIVE.has(op)) return 5;
3883
- if (MULTIPLICATIVE.has(op)) return 6;
3884
- return -1;
3734
+ if ("lt" in pred) {
3735
+ const t = toComparable(pred.lt);
3736
+ return (row) => {
3737
+ const c = toComparable(read(row));
3738
+ return !Number.isNaN(c) && c < t;
3739
+ };
3740
+ }
3741
+ if ("lte" in pred) {
3742
+ const t = toComparable(pred.lte);
3743
+ return (row) => {
3744
+ const c = toComparable(read(row));
3745
+ return !Number.isNaN(c) && c <= t;
3746
+ };
3747
+ }
3748
+ return () => false;
3885
3749
  }
3886
- function describe(t) {
3887
- if (t.type === "eof") return "end of input";
3888
- if (t.type === "str") return `string ${JSON.stringify(t.value)}`;
3889
- if (t.type === "num") return `number ${t.value}`;
3890
- return `"${t.value}"`;
3750
+ function applyFilter(transform2, data) {
3751
+ const test = compilePredicate(transform2.filter);
3752
+ return data.filter(test);
3891
3753
  }
3892
- function parse(src) {
3893
- if (typeof src !== "string" || src.trim() === "") {
3894
- throw new ExpressionError("Expression must be a non-empty string.", 0);
3754
+
3755
+ // src/spec/transform/fold.ts
3756
+ function applyFold(transform2, data) {
3757
+ const [keyName, valueName] = transform2.as ?? ["key", "value"];
3758
+ const out = [];
3759
+ for (const row of data) {
3760
+ for (const column of transform2.fold) {
3761
+ out.push({ ...row, [keyName]: column, [valueName]: row[column] });
3762
+ }
3895
3763
  }
3896
- return new Parser(tokenize(src)).parse();
3764
+ return out;
3897
3765
  }
3898
3766
 
3899
- // src/spec/transform/expr/evaluate.ts
3900
- var BLOCKED_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
3901
- function truthy(v) {
3902
- if (typeof v === "number") return v !== 0 && !Number.isNaN(v);
3903
- return Boolean(v);
3904
- }
3905
- function toStr(v) {
3906
- if (v == null) return "";
3907
- if (v instanceof Date) return v.toISOString();
3908
- return String(v);
3767
+ // src/spec/transform/timeUnit.ts
3768
+ function truncateTo(date, unit) {
3769
+ const year = date.getFullYear();
3770
+ const month = date.getMonth();
3771
+ const day = date.getDate();
3772
+ const hour = date.getHours();
3773
+ const minute = date.getMinutes();
3774
+ const second = date.getSeconds();
3775
+ switch (unit) {
3776
+ case "year":
3777
+ return new Date(year, 0, 1);
3778
+ case "quarter":
3779
+ return new Date(year, Math.floor(month / 3) * 3, 1);
3780
+ case "month":
3781
+ return new Date(year, month, 1);
3782
+ case "week": {
3783
+ const start = new Date(year, month, day - date.getDay());
3784
+ start.setHours(0, 0, 0, 0);
3785
+ return start;
3786
+ }
3787
+ case "day":
3788
+ return new Date(year, month, day);
3789
+ case "hour":
3790
+ return new Date(year, month, day, hour);
3791
+ case "minute":
3792
+ return new Date(year, month, day, hour, minute);
3793
+ case "second":
3794
+ return new Date(year, month, day, hour, minute, second);
3795
+ default:
3796
+ return new Date(date.getTime());
3797
+ }
3909
3798
  }
3910
- function looseEquals(a, b) {
3911
- if (a == null || b == null) return a == null && b == null;
3912
- const na = toNumber(a);
3913
- const nb = toNumber(b);
3914
- if (Number.isFinite(na) && Number.isFinite(nb)) return na === nb;
3915
- return toStr(a) === toStr(b);
3799
+ function applyTimeUnit(transform2, data) {
3800
+ const read = accessor(transform2.field);
3801
+ return data.map((row) => {
3802
+ const date = toDate(read(row));
3803
+ return { ...row, [transform2.as]: date ? truncateTo(date, transform2.timeUnit) : null };
3804
+ });
3916
3805
  }
3917
- function compare(a, b) {
3918
- const na = toNumber(a);
3919
- const nb = toNumber(b);
3920
- if (Number.isFinite(na) && Number.isFinite(nb)) return na < nb ? -1 : na > nb ? 1 : 0;
3921
- const sa = toStr(a);
3922
- const sb = toStr(b);
3923
- return sa < sb ? -1 : sa > sb ? 1 : 0;
3806
+
3807
+ // src/spec/transform/apply.ts
3808
+ function applyTransform(transform2, data) {
3809
+ if ("filter" in transform2) return applyFilter(transform2, data);
3810
+ if ("aggregate" in transform2) return applyAggregate(transform2, data);
3811
+ if ("bin" in transform2) return applyBin(transform2, data);
3812
+ if ("fold" in transform2) return applyFold(transform2, data);
3813
+ if ("timeUnit" in transform2) return applyTimeUnit(transform2, data);
3814
+ if ("calculate" in transform2) return applyCalculate(transform2, data);
3815
+ return data;
3924
3816
  }
3925
- var nums = (args) => args.map(toNumber);
3926
- var FUNCTIONS = {
3927
- abs: (a) => Math.abs(toNumber(a[0])),
3928
- round: (a) => {
3929
- const d = a.length > 1 ? Math.trunc(toNumber(a[1])) : 0;
3930
- const f = 10 ** d;
3931
- return Math.round(toNumber(a[0]) * f) / f;
3932
- },
3933
- floor: (a) => Math.floor(toNumber(a[0])),
3934
- ceil: (a) => Math.ceil(toNumber(a[0])),
3935
- trunc: (a) => Math.trunc(toNumber(a[0])),
3936
- sign: (a) => Math.sign(toNumber(a[0])),
3937
- sqrt: (a) => Math.sqrt(toNumber(a[0])),
3938
- exp: (a) => Math.exp(toNumber(a[0])),
3939
- log: (a) => Math.log(toNumber(a[0])),
3940
- log10: (a) => Math.log10(toNumber(a[0])),
3941
- log2: (a) => Math.log2(toNumber(a[0])),
3942
- pow: (a) => toNumber(a[0]) ** toNumber(a[1]),
3943
- min: (a) => Math.min(...nums(a)),
3944
- max: (a) => Math.max(...nums(a)),
3945
- number: (a) => toNumber(a[0]),
3946
- isFinite: (a) => Number.isFinite(toNumber(a[0])),
3947
- isNaN: (a) => Number.isNaN(toNumber(a[0])),
3948
- str: (a) => toStr(a[0]),
3949
- lower: (a) => toStr(a[0]).toLowerCase(),
3950
- upper: (a) => toStr(a[0]).toUpperCase(),
3951
- trim: (a) => toStr(a[0]).trim(),
3952
- length: (a) => Array.isArray(a[0]) ? a[0].length : toStr(a[0]).length,
3953
- substring: (a) => {
3954
- const s = toStr(a[0]);
3955
- const start = Math.trunc(toNumber(a[1]));
3956
- return a.length > 2 ? s.substring(start, Math.trunc(toNumber(a[2]))) : s.substring(start);
3957
- },
3958
- replace: (a) => toStr(a[0]).split(toStr(a[1])).join(toStr(a[2])),
3959
- contains: (a) => toStr(a[0]).includes(toStr(a[1])),
3960
- startsWith: (a) => toStr(a[0]).startsWith(toStr(a[1])),
3961
- endsWith: (a) => toStr(a[0]).endsWith(toStr(a[1])),
3962
- concat: (a) => a.map(toStr).join(""),
3963
- coalesce: (a) => a.find((v) => v != null),
3964
- if: (a) => truthy(a[0]) ? a[1] : a[2],
3965
- year: (a) => toDate(a[0])?.getFullYear() ?? NaN,
3966
- month: (a) => {
3967
- const d = toDate(a[0]);
3968
- return d ? d.getMonth() + 1 : NaN;
3969
- },
3970
- day: (a) => toDate(a[0])?.getDate() ?? NaN,
3971
- hours: (a) => toDate(a[0])?.getHours() ?? NaN,
3972
- minutes: (a) => toDate(a[0])?.getMinutes() ?? NaN
3973
- };
3974
- var FUNCTION_NAMES = Object.keys(FUNCTIONS);
3975
- function evalNode(node, row) {
3976
- switch (node.type) {
3977
- case "num":
3978
- case "str":
3979
- case "lit":
3980
- return node.value;
3981
- case "datum":
3982
- return row;
3983
- case "field":
3984
- return row[node.name];
3985
- case "unary": {
3986
- const v = evalNode(node.arg, row);
3987
- if (node.op === "!") return !truthy(v);
3988
- if (node.op === "-") return -toNumber(v);
3989
- return toNumber(v);
3990
- }
3991
- case "logical": {
3992
- const left = evalNode(node.left, row);
3993
- if (node.op === "&&") return truthy(left) ? evalNode(node.right, row) : left;
3994
- return truthy(left) ? left : evalNode(node.right, row);
3995
- }
3996
- case "ternary":
3997
- return truthy(evalNode(node.test, row)) ? evalNode(node.then, row) : evalNode(node.alt, row);
3998
- case "binary":
3999
- return evalBinary(node.op, evalNode(node.left, row), evalNode(node.right, row));
4000
- case "member": {
4001
- const obj = evalNode(node.obj, row);
4002
- if (obj == null || typeof obj !== "object") return void 0;
4003
- const key = node.computed ? toStr(evalNode(node.prop, row)) : node.prop.value;
4004
- if (BLOCKED_KEYS.has(key)) return void 0;
4005
- return obj[key];
4006
- }
4007
- case "call": {
4008
- const fn = Object.prototype.hasOwnProperty.call(FUNCTIONS, node.callee) ? FUNCTIONS[node.callee] : void 0;
4009
- if (!fn) throw new ExpressionError(`Unknown function "${node.callee}".`);
4010
- return fn(node.args.map((a) => evalNode(a, row)));
4011
- }
3817
+ function applyTransforms(transforms, data) {
3818
+ if (!transforms || transforms.length === 0) return data;
3819
+ let out = data;
3820
+ for (const transform2 of transforms) {
3821
+ out = applyTransform(transform2, out);
4012
3822
  }
4013
- }
4014
- function evalBinary(op, a, b) {
4015
- switch (op) {
4016
- case "+":
4017
- return typeof a === "string" || typeof b === "string" ? toStr(a) + toStr(b) : toNumber(a) + toNumber(b);
4018
- case "-":
4019
- return toNumber(a) - toNumber(b);
4020
- case "*":
4021
- return toNumber(a) * toNumber(b);
4022
- case "/":
4023
- return toNumber(a) / toNumber(b);
4024
- case "%":
4025
- return toNumber(a) % toNumber(b);
4026
- case "<":
4027
- return compare(a, b) < 0;
4028
- case "<=":
4029
- return compare(a, b) <= 0;
4030
- case ">":
4031
- return compare(a, b) > 0;
4032
- case ">=":
4033
- return compare(a, b) >= 0;
4034
- case "==":
4035
- return looseEquals(a, b);
4036
- case "!=":
4037
- return !looseEquals(a, b);
4038
- case "===":
4039
- return a === b;
4040
- case "!==":
4041
- return a !== b;
4042
- default:
4043
- throw new ExpressionError(`Unknown operator "${op}".`);
4044
- }
4045
- }
4046
- function evaluate(node, row) {
4047
- return evalNode(node, row);
3823
+ return out;
4048
3824
  }
4049
3825
 
4050
- // src/spec/transform/expr/index.ts
4051
- function compileExpression(src) {
4052
- const ast = parse(src);
4053
- return (row) => evaluate(ast, row);
3826
+ // src/spec/lint.ts
3827
+ var MAX_PIE_SLICES = 7;
3828
+ var MAX_SERIES = 12;
3829
+ var MAX_DISCRETE_AXIS = 50;
3830
+ var POSITIONAL_AXES = ["x", "y"];
3831
+ function isObject(v) {
3832
+ return typeof v === "object" && v !== null && !Array.isArray(v);
4054
3833
  }
4055
- function checkExpression(src) {
3834
+ function fieldDef(encoding, channel) {
3835
+ const def = encoding?.[channel];
3836
+ if (isObject(def) && typeof def.field === "string" && def.field !== "") return def;
3837
+ return void 0;
3838
+ }
3839
+ function effectiveData(spec) {
3840
+ const data = Array.isArray(spec.data) ? spec.data : [];
3841
+ if (!("transform" in spec) || !Array.isArray(spec.transform) || spec.transform.length === 0) return data;
4056
3842
  try {
4057
- parse(src);
4058
- return null;
4059
- } catch (e) {
4060
- if (e instanceof ExpressionError) return { message: e.message, pos: e.pos };
4061
- throw e;
3843
+ return applyTransforms(spec.transform, data);
3844
+ } catch {
3845
+ return data;
4062
3846
  }
4063
3847
  }
4064
-
4065
- // src/spec/transform/calculate.ts
4066
- function applyCalculate(transform2, data) {
4067
- const evalRow = compileExpression(transform2.calculate);
4068
- const as = transform2.as;
4069
- return data.map((row) => ({ ...row, [as]: evalRow(row) }));
4070
- }
4071
-
4072
- // src/spec/transform/filter.ts
4073
- function toComparable(value) {
4074
- if (typeof value === "number") return value;
4075
- if (value instanceof Date) return value.getTime();
4076
- const n = toNumber(value);
4077
- if (!Number.isNaN(n)) return n;
4078
- const d = toDate(value);
4079
- return d ? d.getTime() : NaN;
4080
- }
4081
- function compilePredicate(pred) {
4082
- if ("and" in pred) {
4083
- const parts = pred.and.map(compilePredicate);
4084
- return (row) => parts.every((p) => p(row));
4085
- }
4086
- if ("or" in pred) {
4087
- const parts = pred.or.map(compilePredicate);
4088
- return (row) => parts.some((p) => p(row));
4089
- }
4090
- if ("not" in pred) {
4091
- const inner = compilePredicate(pred.not);
4092
- return (row) => !inner(row);
4093
- }
4094
- const read = accessor(pred.field);
4095
- if ("equals" in pred) {
4096
- const want = toKey(pred.equals);
4097
- return (row) => toKey(read(row)) === want;
4098
- }
4099
- if ("ne" in pred) {
4100
- const want = toKey(pred.ne);
4101
- return (row) => toKey(read(row)) !== want;
4102
- }
4103
- if ("oneOf" in pred) {
4104
- const want = new Set(pred.oneOf.map((x) => toKey(x)));
4105
- return (row) => want.has(toKey(read(row)));
3848
+ function lintSpec(spec) {
3849
+ const findings = [];
3850
+ const add = (rule, path, message, severity = "warning") => findings.push({ rule, path, message, severity });
3851
+ const encoding = isObject(spec) ? spec.encoding : void 0;
3852
+ const data = effectiveData(spec);
3853
+ if (data.length === 0) return findings;
3854
+ if (encoding) {
3855
+ for (const channel of ["x", "y", "color", "series"]) {
3856
+ const def = fieldDef(encoding, channel);
3857
+ if (!def) continue;
3858
+ const declared = def.type;
3859
+ if ((declared === "nominal" || declared === "ordinal") && inferType(data, def.field) === "temporal") {
3860
+ findings.push({
3861
+ rule: "temporal-typed-as-categorical",
3862
+ path: `encoding.${channel}.type`,
3863
+ message: `Field "${def.field}" looks temporal but is typed "${declared}". Set type:"temporal" so it scales as time.`,
3864
+ severity: "warning",
3865
+ fix: [{ op: "replace", path: `/encoding/${channel}/type`, value: "temporal" }]
3866
+ });
3867
+ }
3868
+ }
4106
3869
  }
4107
- if ("contains" in pred) {
4108
- const q = String(pred.contains).toLowerCase();
4109
- return (row) => {
4110
- const raw = read(row);
4111
- return raw != null && String(raw).toLowerCase().includes(q);
4112
- };
3870
+ if (spec.type === "pie") {
3871
+ const def = fieldDef(encoding, "color") ?? fieldDef(encoding, "theta");
3872
+ const slices = def ? uniqueValues(data, def.field).length : data.length;
3873
+ if (slices > MAX_PIE_SLICES) {
3874
+ add(
3875
+ "pie-too-many-slices",
3876
+ "encoding.color",
3877
+ `Pie has ${slices} slices (> ${MAX_PIE_SLICES}); proportions get hard to compare. Use a bar chart, or group small slices into "Other".`
3878
+ );
3879
+ }
4113
3880
  }
4114
- if ("valid" in pred) {
4115
- const wantValid = pred.valid !== false;
4116
- return (row) => {
4117
- const raw = read(row);
4118
- const ok = raw != null && !(typeof raw === "number" && Number.isNaN(raw));
4119
- return ok === wantValid;
4120
- };
3881
+ for (const channel of ["series", "color"]) {
3882
+ const def = fieldDef(encoding, channel);
3883
+ if (!def) continue;
3884
+ if (inferType(data, def.field) === "quantitative" && channel === "color") continue;
3885
+ const count = uniqueValues(data, def.field).length;
3886
+ if (count > MAX_SERIES) {
3887
+ add(
3888
+ "too-many-series",
3889
+ `encoding.${channel}`,
3890
+ `"${def.field}" has ${count} distinct values (> ${MAX_SERIES}); the legend/colors become hard to tell apart. Filter, group, or facet instead.`
3891
+ );
3892
+ break;
3893
+ }
4121
3894
  }
4122
- if ("range" in pred) {
4123
- const a = toComparable(pred.range[0]);
4124
- const b = toComparable(pred.range[1]);
4125
- const min = Math.min(a, b);
4126
- const max = Math.max(a, b);
4127
- return (row) => {
4128
- const c = toComparable(read(row));
4129
- return !Number.isNaN(c) && c >= min && c <= max;
4130
- };
3895
+ if (spec.type === "bar") {
3896
+ const y = fieldDef(encoding, "y");
3897
+ const scale = y?.scale;
3898
+ if (scale?.zero === false) {
3899
+ add(
3900
+ "bar-nonzero-baseline",
3901
+ "encoding.y.scale.zero",
3902
+ "Bar lengths encode magnitude, so a non-zero baseline exaggerates differences. Remove scale.zero:false (bars should start at 0)."
3903
+ );
3904
+ } else if (Array.isArray(scale?.domain) && typeof scale.domain[0] === "number" && scale.domain[0] > 0) {
3905
+ add(
3906
+ "bar-nonzero-baseline",
3907
+ "encoding.y.scale.domain",
3908
+ `Bar y-domain starts at ${scale.domain[0]} (> 0); a truncated baseline exaggerates differences. Start the domain at 0.`
3909
+ );
3910
+ }
4131
3911
  }
4132
- if ("gt" in pred) {
4133
- const t = toComparable(pred.gt);
4134
- return (row) => {
4135
- const c = toComparable(read(row));
4136
- return !Number.isNaN(c) && c > t;
4137
- };
3912
+ if (encoding) {
3913
+ for (const channel of ["x", "y"]) {
3914
+ const def = fieldDef(encoding, channel);
3915
+ if (def?.scale?.type !== "log") continue;
3916
+ const read = accessor(def.field);
3917
+ const hasNonPositive = data.some((d) => {
3918
+ const n = toNumber(read(d));
3919
+ return Number.isFinite(n) && n <= 0;
3920
+ });
3921
+ if (hasNonPositive) {
3922
+ add(
3923
+ "log-nonpositive-data",
3924
+ `encoding.${channel}.scale`,
3925
+ `"${def.field}" has values \u2264 0, which a log scale cannot plot. Use a linear scale or filter non-positive values.`
3926
+ );
3927
+ }
3928
+ }
4138
3929
  }
4139
- if ("gte" in pred) {
4140
- const t = toComparable(pred.gte);
4141
- return (row) => {
4142
- const c = toComparable(read(row));
4143
- return !Number.isNaN(c) && c >= t;
4144
- };
3930
+ if (spec.type === "bar" || spec.type === "line" || spec.type === "area") {
3931
+ for (const channel of POSITIONAL_AXES) {
3932
+ const def = fieldDef(encoding, channel);
3933
+ if (!def) continue;
3934
+ const t = def.type ?? inferType(data, def.field);
3935
+ const discrete = t === "nominal" || t === "ordinal" || def.scale?.type === "band" || def.scale?.type === "point";
3936
+ if (!discrete) continue;
3937
+ const count = uniqueValues(data, def.field).length;
3938
+ if (count > MAX_DISCRETE_AXIS) {
3939
+ add(
3940
+ "high-cardinality-axis",
3941
+ `encoding.${channel}`,
3942
+ `"${def.field}" has ${count} categories on the ${channel}-axis (> ${MAX_DISCRETE_AXIS}); labels will be unreadable. Aggregate, filter to a top-N, or use a different chart.`
3943
+ );
3944
+ }
3945
+ }
4145
3946
  }
4146
- if ("lt" in pred) {
4147
- const t = toComparable(pred.lt);
4148
- return (row) => {
4149
- const c = toComparable(read(row));
4150
- return !Number.isNaN(c) && c < t;
4151
- };
3947
+ if (spec.type === "histogram") {
3948
+ const def = fieldDef(encoding, "x");
3949
+ if (def) {
3950
+ const t = def.type ?? inferType(data, def.field);
3951
+ if (t !== "quantitative") {
3952
+ add(
3953
+ "histogram-nonnumeric-field",
3954
+ "encoding.x",
3955
+ `Histogram field "${def.field}" is ${t}, not quantitative \u2014 binning needs numbers. Use a numeric field, or a bar chart for categories.`
3956
+ );
3957
+ }
3958
+ }
4152
3959
  }
4153
- if ("lte" in pred) {
4154
- const t = toComparable(pred.lte);
4155
- return (row) => {
4156
- const c = toComparable(read(row));
4157
- return !Number.isNaN(c) && c <= t;
4158
- };
3960
+ if (spec.type === "combo" && Array.isArray(spec.layers)) {
3961
+ const layers = spec.layers;
3962
+ const hasRight = layers.some((l) => isObject(l) && l.axis === "right");
3963
+ const hasLeft = layers.some((l) => isObject(l) && l.axis !== "right");
3964
+ if (hasRight && hasLeft) {
3965
+ add(
3966
+ "combo-dual-axis",
3967
+ "layers",
3968
+ "Dual-axis combo: a secondary y-axis can suggest correlations that are not real. Use it only for genuinely different units/scales, and label both axes clearly.",
3969
+ "info"
3970
+ );
3971
+ }
4159
3972
  }
4160
- return () => false;
4161
- }
4162
- function applyFilter(transform2, data) {
4163
- const test = compilePredicate(transform2.filter);
4164
- return data.filter(test);
3973
+ return findings;
4165
3974
  }
4166
3975
 
4167
- // src/spec/transform/fold.ts
4168
- function applyFold(transform2, data) {
4169
- const [keyName, valueName] = transform2.as ?? ["key", "value"];
4170
- const out = [];
4171
- for (const row of data) {
4172
- for (const column of transform2.fold) {
4173
- out.push({ ...row, [keyName]: column, [valueName]: row[column] });
3976
+ // src/spec/suggest.ts
3977
+ function levenshtein(a, b) {
3978
+ if (a === b) return 0;
3979
+ const m = a.length;
3980
+ const n = b.length;
3981
+ if (m === 0) return n;
3982
+ if (n === 0) return m;
3983
+ let prev = new Array(n + 1);
3984
+ let curr = new Array(n + 1);
3985
+ for (let j = 0; j <= n; j++) prev[j] = j;
3986
+ for (let i = 1; i <= m; i++) {
3987
+ curr[0] = i;
3988
+ const ca = a.charCodeAt(i - 1);
3989
+ for (let j = 1; j <= n; j++) {
3990
+ const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
3991
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
4174
3992
  }
3993
+ [prev, curr] = [curr, prev];
4175
3994
  }
4176
- return out;
3995
+ return prev[n];
4177
3996
  }
4178
-
4179
- // src/spec/transform/timeUnit.ts
4180
- function truncateTo(date, unit) {
4181
- const year = date.getFullYear();
4182
- const month = date.getMonth();
4183
- const day = date.getDate();
4184
- const hour = date.getHours();
4185
- const minute = date.getMinutes();
4186
- const second = date.getSeconds();
4187
- switch (unit) {
4188
- case "year":
4189
- return new Date(year, 0, 1);
4190
- case "quarter":
4191
- return new Date(year, Math.floor(month / 3) * 3, 1);
4192
- case "month":
4193
- return new Date(year, month, 1);
4194
- case "week": {
4195
- const start = new Date(year, month, day - date.getDay());
4196
- start.setHours(0, 0, 0, 0);
4197
- return start;
4198
- }
4199
- case "day":
4200
- return new Date(year, month, day);
4201
- case "hour":
4202
- return new Date(year, month, day, hour);
4203
- case "minute":
4204
- return new Date(year, month, day, hour, minute);
4205
- case "second":
4206
- return new Date(year, month, day, hour, minute, second);
4207
- default:
4208
- return new Date(date.getTime());
4209
- }
3997
+ function tolerance(input) {
3998
+ return Math.max(2, Math.ceil(input.length * 0.5));
4210
3999
  }
4211
- function applyTimeUnit(transform2, data) {
4212
- const read = accessor(transform2.field);
4213
- return data.map((row) => {
4214
- const date = toDate(read(row));
4215
- return { ...row, [transform2.as]: date ? truncateTo(date, transform2.timeUnit) : null };
4216
- });
4000
+ function rank(input, candidates) {
4001
+ const lower = input.toLowerCase();
4002
+ return candidates.map((value) => {
4003
+ const lc = value.toLowerCase();
4004
+ const distance = lc === lower ? 0 : levenshtein(lower, lc);
4005
+ return { value, distance };
4006
+ }).sort((a, b) => a.distance - b.distance || a.value.localeCompare(b.value));
4217
4007
  }
4218
-
4219
- // src/spec/transform/apply.ts
4220
- function applyTransform(transform2, data) {
4221
- if ("filter" in transform2) return applyFilter(transform2, data);
4222
- if ("aggregate" in transform2) return applyAggregate(transform2, data);
4223
- if ("bin" in transform2) return applyBin(transform2, data);
4224
- if ("fold" in transform2) return applyFold(transform2, data);
4225
- if ("timeUnit" in transform2) return applyTimeUnit(transform2, data);
4226
- if ("calculate" in transform2) return applyCalculate(transform2, data);
4227
- return data;
4008
+ function suggestions(input, candidates, limit = 3, maxDistance = tolerance(input)) {
4009
+ if (!input) return [];
4010
+ return rank(input, candidates).filter((r) => r.distance <= maxDistance).slice(0, limit).map((r) => r.value);
4228
4011
  }
4229
- function applyTransforms(transforms, data) {
4230
- if (!transforms || transforms.length === 0) return data;
4231
- let out = data;
4232
- for (const transform2 of transforms) {
4233
- out = applyTransform(transform2, out);
4234
- }
4235
- return out;
4012
+ function closest(input, candidates) {
4013
+ if (!input) return void 0;
4014
+ const ranked = rank(input, candidates);
4015
+ if (ranked.length === 0) return void 0;
4016
+ const best = ranked[0];
4017
+ const bound = Math.max(2, Math.floor(input.length * 0.34));
4018
+ if (best.distance > bound) return void 0;
4019
+ const runnerUp = ranked[1];
4020
+ if (runnerUp && runnerUp.distance === best.distance) return void 0;
4021
+ return best.value;
4236
4022
  }
4237
4023
 
4238
- // src/spec/infer.ts
4239
- function looksTemporal2(value) {
4240
- if (value.length < 4) return false;
4241
- if (/^\d{4}(-\d{2}(-\d{2})?)?([T\s]\d{2}:\d{2})?/.test(value)) {
4242
- const t = Date.parse(value);
4243
- return !Number.isNaN(t);
4024
+ // src/spec/jsonpatch.ts
4025
+ function clone(value) {
4026
+ if (value === null || typeof value !== "object") return value;
4027
+ if (value instanceof Date) return new Date(value.getTime());
4028
+ if (Array.isArray(value)) return value.map((v) => clone(v));
4029
+ const out = {};
4030
+ for (const key of Object.keys(value)) {
4031
+ out[key] = clone(value[key]);
4244
4032
  }
4245
- return false;
4033
+ return out;
4246
4034
  }
4247
- function inferValueType(value) {
4248
- if (value == null) return void 0;
4249
- if (typeof value === "number") return Number.isFinite(value) ? "quantitative" : void 0;
4250
- if (value instanceof Date) return "temporal";
4251
- if (typeof value === "boolean") return "nominal";
4252
- if (typeof value === "string") {
4253
- if (value.trim() === "") return void 0;
4254
- if (looksTemporal2(value)) return "temporal";
4255
- if (/^-?\d*\.?\d+(e[-+]?\d+)?$/i.test(value.trim())) return "quantitative";
4256
- return "nominal";
4257
- }
4258
- return "nominal";
4035
+ function decode(token) {
4036
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
4259
4037
  }
4260
- function inferFieldType(data, field, sample = 50) {
4261
- let seen = 0;
4262
- const counts = {
4263
- quantitative: 0,
4264
- temporal: 0,
4265
- ordinal: 0,
4266
- nominal: 0
4267
- };
4268
- for (let i = 0; i < data.length && seen < sample; i++) {
4269
- const t = inferValueType(data[i]?.[field]);
4270
- if (!t) continue;
4271
- counts[t]++;
4272
- seen++;
4273
- }
4274
- if (seen === 0) return "nominal";
4275
- if (counts.temporal >= seen * 0.6) return "temporal";
4276
- if (counts.quantitative >= seen * 0.6) return "quantitative";
4277
- return "nominal";
4038
+ function parsePointer(pointer) {
4039
+ if (pointer === "") return [];
4040
+ if (pointer[0] !== "/") throw new Error(`Invalid JSON Pointer: ${pointer}`);
4041
+ return pointer.slice(1).split("/").map(decode);
4278
4042
  }
4279
- function inferFieldTypes(data) {
4280
- const fields = /* @__PURE__ */ new Set();
4281
- for (let i = 0; i < Math.min(data.length, 20); i++) {
4282
- const row = data[i];
4283
- if (row) for (const k of Object.keys(row)) fields.add(k);
4043
+ function toPointer(path) {
4044
+ if (path === "") return "";
4045
+ const parts = [];
4046
+ for (const seg of path.replace(/\[(\d+)\]/g, ".$1").split(".")) {
4047
+ if (seg === "") continue;
4048
+ parts.push(seg.replace(/~/g, "~0").replace(/\//g, "~1"));
4284
4049
  }
4285
- const out = {};
4286
- for (const f of fields) out[f] = inferFieldType(data, f);
4287
- return out;
4288
- }
4289
-
4290
- // src/spec/lint.ts
4291
- var MAX_PIE_SLICES = 7;
4292
- var MAX_SERIES = 12;
4293
- var MAX_DISCRETE_AXIS = 50;
4294
- var POSITIONAL_AXES = ["x", "y"];
4295
- function isObject(v) {
4296
- return typeof v === "object" && v !== null && !Array.isArray(v);
4050
+ return "/" + parts.join("/");
4297
4051
  }
4298
- function fieldDef(encoding, channel) {
4299
- const def = encoding?.[channel];
4300
- if (isObject(def) && typeof def.field === "string" && def.field !== "") return def;
4301
- return void 0;
4052
+ function isObject2(v) {
4053
+ return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
4302
4054
  }
4303
- function effectiveData(spec) {
4304
- const data = Array.isArray(spec.data) ? spec.data : [];
4305
- if (!("transform" in spec) || !Array.isArray(spec.transform) || spec.transform.length === 0) return data;
4306
- try {
4307
- return applyTransforms(spec.transform, data);
4308
- } catch {
4309
- return data;
4055
+ function applyOne(doc, op) {
4056
+ const segments = parsePointer(op.path);
4057
+ if (segments.length === 0) {
4058
+ return op.op === "remove" ? doc : op.value;
4310
4059
  }
4311
- }
4312
- function lintSpec(spec) {
4313
- const findings = [];
4314
- const add = (rule, path, message, severity = "warning") => findings.push({ rule, path, message, severity });
4315
- const encoding = isObject(spec) ? spec.encoding : void 0;
4316
- const data = effectiveData(spec);
4317
- if (data.length === 0) return findings;
4318
- if (encoding) {
4319
- for (const channel of ["x", "y", "color", "series"]) {
4320
- const def = fieldDef(encoding, channel);
4321
- if (!def) continue;
4322
- const declared = def.type;
4323
- if ((declared === "nominal" || declared === "ordinal") && inferType(data, def.field) === "temporal") {
4324
- findings.push({
4325
- rule: "temporal-typed-as-categorical",
4326
- path: `encoding.${channel}.type`,
4327
- message: `Field "${def.field}" looks temporal but is typed "${declared}". Set type:"temporal" so it scales as time.`,
4328
- severity: "warning",
4329
- fix: [{ op: "replace", path: `/encoding/${channel}/type`, value: "temporal" }]
4330
- });
4331
- }
4060
+ let parent = doc;
4061
+ for (let i = 0; i < segments.length - 1; i++) {
4062
+ const seg = segments[i];
4063
+ if (Array.isArray(parent)) {
4064
+ parent = parent[Number(seg)];
4065
+ } else if (isObject2(parent)) {
4066
+ parent = parent[seg];
4067
+ } else {
4068
+ return doc;
4332
4069
  }
4070
+ if (parent === void 0 || parent === null) return doc;
4333
4071
  }
4334
- if (spec.type === "pie") {
4335
- const def = fieldDef(encoding, "color") ?? fieldDef(encoding, "theta");
4336
- const slices = def ? uniqueValues(data, def.field).length : data.length;
4337
- if (slices > MAX_PIE_SLICES) {
4338
- add(
4339
- "pie-too-many-slices",
4340
- "encoding.color",
4341
- `Pie has ${slices} slices (> ${MAX_PIE_SLICES}); proportions get hard to compare. Use a bar chart, or group small slices into "Other".`
4342
- );
4343
- }
4344
- }
4345
- for (const channel of ["series", "color"]) {
4346
- const def = fieldDef(encoding, channel);
4347
- if (!def) continue;
4348
- if (inferType(data, def.field) === "quantitative" && channel === "color") continue;
4349
- const count = uniqueValues(data, def.field).length;
4350
- if (count > MAX_SERIES) {
4351
- add(
4352
- "too-many-series",
4353
- `encoding.${channel}`,
4354
- `"${def.field}" has ${count} distinct values (> ${MAX_SERIES}); the legend/colors become hard to tell apart. Filter, group, or facet instead.`
4355
- );
4356
- break;
4357
- }
4358
- }
4359
- if (spec.type === "bar") {
4360
- const y = fieldDef(encoding, "y");
4361
- const scale = y?.scale;
4362
- if (scale?.zero === false) {
4363
- add(
4364
- "bar-nonzero-baseline",
4365
- "encoding.y.scale.zero",
4366
- "Bar lengths encode magnitude, so a non-zero baseline exaggerates differences. Remove scale.zero:false (bars should start at 0)."
4367
- );
4368
- } else if (Array.isArray(scale?.domain) && typeof scale.domain[0] === "number" && scale.domain[0] > 0) {
4369
- add(
4370
- "bar-nonzero-baseline",
4371
- "encoding.y.scale.domain",
4372
- `Bar y-domain starts at ${scale.domain[0]} (> 0); a truncated baseline exaggerates differences. Start the domain at 0.`
4373
- );
4374
- }
4375
- }
4376
- if (encoding) {
4377
- for (const channel of ["x", "y"]) {
4378
- const def = fieldDef(encoding, channel);
4379
- if (def?.scale?.type !== "log") continue;
4380
- const read = accessor(def.field);
4381
- const hasNonPositive = data.some((d) => {
4382
- const n = toNumber(read(d));
4383
- return Number.isFinite(n) && n <= 0;
4384
- });
4385
- if (hasNonPositive) {
4386
- add(
4387
- "log-nonpositive-data",
4388
- `encoding.${channel}.scale`,
4389
- `"${def.field}" has values \u2264 0, which a log scale cannot plot. Use a linear scale or filter non-positive values.`
4390
- );
4391
- }
4392
- }
4393
- }
4394
- if (spec.type === "bar" || spec.type === "line" || spec.type === "area") {
4395
- for (const channel of POSITIONAL_AXES) {
4396
- const def = fieldDef(encoding, channel);
4397
- if (!def) continue;
4398
- const t = def.type ?? inferType(data, def.field);
4399
- const discrete = t === "nominal" || t === "ordinal" || def.scale?.type === "band" || def.scale?.type === "point";
4400
- if (!discrete) continue;
4401
- const count = uniqueValues(data, def.field).length;
4402
- if (count > MAX_DISCRETE_AXIS) {
4403
- add(
4404
- "high-cardinality-axis",
4405
- `encoding.${channel}`,
4406
- `"${def.field}" has ${count} categories on the ${channel}-axis (> ${MAX_DISCRETE_AXIS}); labels will be unreadable. Aggregate, filter to a top-N, or use a different chart.`
4407
- );
4408
- }
4409
- }
4410
- }
4411
- if (spec.type === "histogram") {
4412
- const def = fieldDef(encoding, "x");
4413
- if (def) {
4414
- const t = def.type ?? inferType(data, def.field);
4415
- if (t !== "quantitative") {
4416
- add(
4417
- "histogram-nonnumeric-field",
4418
- "encoding.x",
4419
- `Histogram field "${def.field}" is ${t}, not quantitative \u2014 binning needs numbers. Use a numeric field, or a bar chart for categories.`
4420
- );
4421
- }
4422
- }
4423
- }
4424
- if (spec.type === "combo" && Array.isArray(spec.layers)) {
4425
- const layers = spec.layers;
4426
- const hasRight = layers.some((l) => isObject(l) && l.axis === "right");
4427
- const hasLeft = layers.some((l) => isObject(l) && l.axis !== "right");
4428
- if (hasRight && hasLeft) {
4429
- add(
4430
- "combo-dual-axis",
4431
- "layers",
4432
- "Dual-axis combo: a secondary y-axis can suggest correlations that are not real. Use it only for genuinely different units/scales, and label both axes clearly.",
4433
- "info"
4434
- );
4435
- }
4436
- }
4437
- return findings;
4438
- }
4439
-
4440
- // src/spec/suggest.ts
4441
- function levenshtein(a, b) {
4442
- if (a === b) return 0;
4443
- const m = a.length;
4444
- const n = b.length;
4445
- if (m === 0) return n;
4446
- if (n === 0) return m;
4447
- let prev = new Array(n + 1);
4448
- let curr = new Array(n + 1);
4449
- for (let j = 0; j <= n; j++) prev[j] = j;
4450
- for (let i = 1; i <= m; i++) {
4451
- curr[0] = i;
4452
- const ca = a.charCodeAt(i - 1);
4453
- for (let j = 1; j <= n; j++) {
4454
- const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
4455
- curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
4456
- }
4457
- [prev, curr] = [curr, prev];
4458
- }
4459
- return prev[n];
4460
- }
4461
- function tolerance(input) {
4462
- return Math.max(2, Math.ceil(input.length * 0.5));
4463
- }
4464
- function rank(input, candidates) {
4465
- const lower = input.toLowerCase();
4466
- return candidates.map((value) => {
4467
- const lc = value.toLowerCase();
4468
- const distance = lc === lower ? 0 : levenshtein(lower, lc);
4469
- return { value, distance };
4470
- }).sort((a, b) => a.distance - b.distance || a.value.localeCompare(b.value));
4471
- }
4472
- function suggestions(input, candidates, limit = 3, maxDistance = tolerance(input)) {
4473
- if (!input) return [];
4474
- return rank(input, candidates).filter((r) => r.distance <= maxDistance).slice(0, limit).map((r) => r.value);
4475
- }
4476
- function closest(input, candidates) {
4477
- if (!input) return void 0;
4478
- const ranked = rank(input, candidates);
4479
- if (ranked.length === 0) return void 0;
4480
- const best = ranked[0];
4481
- const bound = Math.max(2, Math.floor(input.length * 0.34));
4482
- if (best.distance > bound) return void 0;
4483
- const runnerUp = ranked[1];
4484
- if (runnerUp && runnerUp.distance === best.distance) return void 0;
4485
- return best.value;
4486
- }
4487
-
4488
- // src/spec/jsonpatch.ts
4489
- function clone(value) {
4490
- if (value === null || typeof value !== "object") return value;
4491
- if (value instanceof Date) return new Date(value.getTime());
4492
- if (Array.isArray(value)) return value.map((v) => clone(v));
4493
- const out = {};
4494
- for (const key of Object.keys(value)) {
4495
- out[key] = clone(value[key]);
4496
- }
4497
- return out;
4498
- }
4499
- function decode(token) {
4500
- return token.replace(/~1/g, "/").replace(/~0/g, "~");
4501
- }
4502
- function parsePointer(pointer) {
4503
- if (pointer === "") return [];
4504
- if (pointer[0] !== "/") throw new Error(`Invalid JSON Pointer: ${pointer}`);
4505
- return pointer.slice(1).split("/").map(decode);
4506
- }
4507
- function toPointer(path) {
4508
- if (path === "") return "";
4509
- const parts = [];
4510
- for (const seg of path.replace(/\[(\d+)\]/g, ".$1").split(".")) {
4511
- if (seg === "") continue;
4512
- parts.push(seg.replace(/~/g, "~0").replace(/\//g, "~1"));
4513
- }
4514
- return "/" + parts.join("/");
4515
- }
4516
- function isObject2(v) {
4517
- return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
4518
- }
4519
- function applyOne(doc, op) {
4520
- const segments = parsePointer(op.path);
4521
- if (segments.length === 0) {
4522
- return op.op === "remove" ? doc : op.value;
4523
- }
4524
- let parent = doc;
4525
- for (let i = 0; i < segments.length - 1; i++) {
4526
- const seg = segments[i];
4527
- if (Array.isArray(parent)) {
4528
- parent = parent[Number(seg)];
4529
- } else if (isObject2(parent)) {
4530
- parent = parent[seg];
4531
- } else {
4532
- return doc;
4533
- }
4534
- if (parent === void 0 || parent === null) return doc;
4535
- }
4536
- const key = segments[segments.length - 1];
4537
- if (Array.isArray(parent)) {
4538
- const idx = key === "-" ? parent.length : Number(key);
4539
- if (Number.isNaN(idx)) return doc;
4540
- if (op.op === "remove") {
4541
- if (idx >= 0 && idx < parent.length) parent.splice(idx, 1);
4542
- } else if (op.op === "add") {
4543
- parent.splice(Math.min(idx, parent.length), 0, op.value);
4544
- } else {
4545
- if (idx >= 0 && idx < parent.length) parent[idx] = op.value;
4546
- }
4547
- } else if (isObject2(parent)) {
4548
- if (op.op === "remove") {
4549
- delete parent[key];
4550
- } else {
4551
- parent[key] = op.value;
4072
+ const key = segments[segments.length - 1];
4073
+ if (Array.isArray(parent)) {
4074
+ const idx = key === "-" ? parent.length : Number(key);
4075
+ if (Number.isNaN(idx)) return doc;
4076
+ if (op.op === "remove") {
4077
+ if (idx >= 0 && idx < parent.length) parent.splice(idx, 1);
4078
+ } else if (op.op === "add") {
4079
+ parent.splice(Math.min(idx, parent.length), 0, op.value);
4080
+ } else {
4081
+ if (idx >= 0 && idx < parent.length) parent[idx] = op.value;
4082
+ }
4083
+ } else if (isObject2(parent)) {
4084
+ if (op.op === "remove") {
4085
+ delete parent[key];
4086
+ } else {
4087
+ parent[key] = op.value;
4552
4088
  }
4553
4089
  }
4554
4090
  return doc;
@@ -5748,16 +5284,693 @@ function validateDashboard(spec) {
5748
5284
  }
5749
5285
  }
5750
5286
  }
5751
- return { valid: errors.length === 0, errors, warnings };
5287
+ return { valid: errors.length === 0, errors, warnings };
5288
+ }
5289
+ function assertValidSpec(spec) {
5290
+ const { valid, errors } = validateSpec(spec);
5291
+ if (!valid) {
5292
+ const detail = errors.map((e) => ` - ${e.path || "(root)"}: ${e.message}`).join("\n");
5293
+ throw new Error(`Invalid Graphein chart spec:
5294
+ ${detail}`);
5295
+ }
5296
+ return spec;
5297
+ }
5298
+
5299
+ // src/analyze/recommend.ts
5300
+ function profileColumns(data) {
5301
+ const fields = /* @__PURE__ */ new Set();
5302
+ for (const row of data) {
5303
+ for (const field of Object.keys(row)) fields.add(field);
5304
+ }
5305
+ return [...fields].map((field) => ({
5306
+ field,
5307
+ type: inferType(data, field),
5308
+ cardinality: uniqueValues(data, field).length
5309
+ }));
5310
+ }
5311
+ function title(text) {
5312
+ return text.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w/g, (m) => m.toUpperCase());
5313
+ }
5314
+ function fieldDef2(profile) {
5315
+ return { field: profile.field, type: profile.type };
5316
+ }
5317
+ function boost(score, family, intent) {
5318
+ if (!intent) return score;
5319
+ const matches = family === intent || intent === "composition" && family === "comparison" || intent === "comparison" && family === "density";
5320
+ return Math.min(1, score + (matches ? 0.12 : 0));
5321
+ }
5322
+ function pushValid(candidates, candidate, intent) {
5323
+ const score = Number(boost(candidate.score, candidate.family, intent).toFixed(3));
5324
+ const spec = candidate.spec;
5325
+ if (!validateSpec(spec).valid) return;
5326
+ candidates.push({ ...candidate, score });
5327
+ }
5328
+ function firstLowCardNominal(profiles, exclude = /* @__PURE__ */ new Set()) {
5329
+ return profiles.find((p) => p.type === "nominal" && p.cardinality <= 12 && !exclude.has(p.field));
5330
+ }
5331
+ function recommendChart(data, opts = {}) {
5332
+ if (!Array.isArray(data) || data.length === 0) return [];
5333
+ const profiles = profileColumns(data);
5334
+ const quantitative = profiles.filter((p) => p.type === "quantitative");
5335
+ const ordered = profiles.filter((p) => p.type === "temporal" || p.type === "ordinal");
5336
+ const nominal = profiles.filter((p) => p.type === "nominal");
5337
+ const lowNominal = nominal.filter((p) => p.cardinality <= 12);
5338
+ const manyNominal = nominal.filter((p) => p.cardinality > 12);
5339
+ const candidates = [];
5340
+ const measure = quantitative[0];
5341
+ if (ordered.length > 0 && measure) {
5342
+ const x = ordered[0];
5343
+ const encoding = { x: fieldDef2(x), y: fieldDef2(measure) };
5344
+ const series = firstLowCardNominal(profiles, /* @__PURE__ */ new Set([x.field, measure.field]));
5345
+ if (series) encoding.series = fieldDef2(series);
5346
+ pushValid(
5347
+ candidates,
5348
+ {
5349
+ spec: {
5350
+ type: "line",
5351
+ data,
5352
+ encoding,
5353
+ title: `${title(measure.field)} over ${title(x.field)}`
5354
+ },
5355
+ score: 0.92,
5356
+ family: "trend",
5357
+ rationale: `${title(x.field)} is ${x.type}, so a line chart shows how ${title(measure.field)} changes over ordered rows${series ? `, split by low-cardinality ${title(series.field)}` : ""}.`
5358
+ },
5359
+ opts.intent
5360
+ );
5361
+ }
5362
+ if (measure) {
5363
+ const category = lowNominal[0];
5364
+ if (category) {
5365
+ pushValid(
5366
+ candidates,
5367
+ {
5368
+ spec: {
5369
+ type: "bar",
5370
+ data,
5371
+ encoding: { x: fieldDef2(category), y: fieldDef2(measure) },
5372
+ title: `${title(measure.field)} by ${title(category.field)}`
5373
+ },
5374
+ score: 0.84,
5375
+ family: "comparison",
5376
+ rationale: `${title(category.field)} has ${category.cardinality} categories, making bars a good comparison of ${title(measure.field)}.`
5377
+ },
5378
+ opts.intent
5379
+ );
5380
+ if (category.cardinality <= 7) {
5381
+ pushValid(
5382
+ candidates,
5383
+ {
5384
+ spec: {
5385
+ type: "pie",
5386
+ data,
5387
+ encoding: { theta: fieldDef2(measure), color: fieldDef2(category) },
5388
+ title: `${title(measure.field)} share by ${title(category.field)}`
5389
+ },
5390
+ score: 0.58,
5391
+ family: "composition",
5392
+ rationale: `${title(category.field)} has ${category.cardinality} slices, so a pie can show part-to-whole share for ${title(measure.field)}.`
5393
+ },
5394
+ opts.intent
5395
+ );
5396
+ }
5397
+ } else if (manyNominal.length > 0) {
5398
+ const wideCategory = manyNominal[0];
5399
+ pushValid(
5400
+ candidates,
5401
+ {
5402
+ spec: {
5403
+ type: "bar",
5404
+ data,
5405
+ encoding: { x: fieldDef2(wideCategory), y: fieldDef2(measure) },
5406
+ title: `${title(measure.field)} by ${title(wideCategory.field)}`
5407
+ },
5408
+ score: 0.62,
5409
+ family: "comparison",
5410
+ rationale: `${title(wideCategory.field)} has ${wideCategory.cardinality} categories; use this bar chart with top-N filtering or grouping to keep labels readable.`
5411
+ },
5412
+ opts.intent
5413
+ );
5414
+ }
5415
+ }
5416
+ if (quantitative.length >= 2) {
5417
+ const [x, y] = quantitative;
5418
+ const encoding = { x: fieldDef2(x), y: fieldDef2(y) };
5419
+ const size = quantitative[2];
5420
+ const color = firstLowCardNominal(profiles, /* @__PURE__ */ new Set([x.field, y.field]));
5421
+ if (size) encoding.size = fieldDef2(size);
5422
+ if (color) encoding.color = fieldDef2(color);
5423
+ pushValid(
5424
+ candidates,
5425
+ {
5426
+ spec: {
5427
+ type: "scatter",
5428
+ data,
5429
+ encoding,
5430
+ title: `${title(y.field)} vs ${title(x.field)}`
5431
+ },
5432
+ score: 0.86,
5433
+ family: "relationship",
5434
+ rationale: `${title(x.field)} and ${title(y.field)} are quantitative, so scatter reveals their relationship${size ? ` with ${title(size.field)} as bubble size` : ""}${color ? ` and ${title(color.field)} as color` : ""}.`
5435
+ },
5436
+ opts.intent
5437
+ );
5438
+ }
5439
+ if (quantitative.length === 1) {
5440
+ const q = quantitative[0];
5441
+ pushValid(
5442
+ candidates,
5443
+ {
5444
+ spec: {
5445
+ type: "histogram",
5446
+ data,
5447
+ encoding: { x: fieldDef2(q) },
5448
+ title: `${title(q.field)} distribution`
5449
+ },
5450
+ score: 0.78,
5451
+ family: "distribution",
5452
+ rationale: `${title(q.field)} is the only quantitative field, so a histogram shows its distribution.`
5453
+ },
5454
+ opts.intent
5455
+ );
5456
+ pushValid(
5457
+ candidates,
5458
+ {
5459
+ spec: {
5460
+ type: "kpi",
5461
+ data,
5462
+ value: { field: q.field, aggregate: "sum" },
5463
+ label: `Total ${title(q.field)}`,
5464
+ title: `Total ${title(q.field)}`
5465
+ },
5466
+ score: 0.62,
5467
+ family: "value",
5468
+ rationale: `${title(q.field)} is the only quantitative field, so a KPI summarizes it as a total.`
5469
+ },
5470
+ opts.intent
5471
+ );
5472
+ }
5473
+ if (lowNominal.length >= 2 && measure) {
5474
+ const [x, y] = lowNominal;
5475
+ pushValid(
5476
+ candidates,
5477
+ {
5478
+ spec: {
5479
+ type: "heatmap",
5480
+ data,
5481
+ encoding: { x: fieldDef2(x), y: fieldDef2(y), color: fieldDef2(measure) },
5482
+ title: `${title(measure.field)} by ${title(x.field)} and ${title(y.field)}`
5483
+ },
5484
+ score: 0.76,
5485
+ family: "density",
5486
+ rationale: `${title(x.field)} and ${title(y.field)} are low-cardinality categories, so a heatmap shows ${title(measure.field)} across their combinations.`
5487
+ },
5488
+ opts.intent
5489
+ );
5490
+ pushValid(
5491
+ candidates,
5492
+ {
5493
+ spec: {
5494
+ type: "matrix",
5495
+ data,
5496
+ rows: [y.field],
5497
+ columns: [x.field],
5498
+ values: [{ field: measure.field, op: "sum" }],
5499
+ title: `${title(measure.field)} matrix`
5500
+ },
5501
+ score: 0.68,
5502
+ family: "density",
5503
+ rationale: `A matrix summarizes ${title(measure.field)} by ${title(y.field)} rows and ${title(x.field)} columns.`
5504
+ },
5505
+ opts.intent
5506
+ );
5507
+ }
5508
+ return candidates.sort((a, b) => b.score - a.score).slice(0, opts.maxResults ?? 5).map(({ spec, score, rationale }) => ({ spec, score, rationale }));
5509
+ }
5510
+
5511
+ // src/render/env.ts
5512
+ var isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
5513
+ function getDevicePixelRatio() {
5514
+ const dpr = globalThis.devicePixelRatio;
5515
+ return typeof dpr === "number" && dpr > 0 ? dpr : 1;
5516
+ }
5517
+
5518
+ // src/render/sizing.ts
5519
+ function computeBackingSize(width, height, dpr) {
5520
+ const d = dpr > 0 ? dpr : 1;
5521
+ const cssWidth = Math.max(0, width);
5522
+ const cssHeight = Math.max(0, height);
5523
+ return {
5524
+ cssWidth,
5525
+ cssHeight,
5526
+ pixelWidth: Math.max(1, Math.round(cssWidth * d)),
5527
+ pixelHeight: Math.max(1, Math.round(cssHeight * d)),
5528
+ dpr: d
5529
+ };
5530
+ }
5531
+
5532
+ // src/render/dom.ts
5533
+ function setStyle(el, style) {
5534
+ Object.assign(el.style, style);
5535
+ }
5536
+ function createDiv(className, style) {
5537
+ const el = document.createElement("div");
5538
+ if (className) el.className = className;
5539
+ if (style) setStyle(el, style);
5540
+ return el;
5541
+ }
5542
+ var fillParentStyle = {
5543
+ position: "absolute",
5544
+ top: "0",
5545
+ left: "0"
5546
+ };
5547
+
5548
+ // src/render/canvasLayer.ts
5549
+ var CanvasLayer = class {
5550
+ constructor(canvas) {
5551
+ __publicField(this, "canvas");
5552
+ __publicField(this, "ctx");
5553
+ /** Logical (CSS px) width. */
5554
+ __publicField(this, "width", 0);
5555
+ /** Logical (CSS px) height. */
5556
+ __publicField(this, "height", 0);
5557
+ __publicField(this, "dpr", 1);
5558
+ this.canvas = canvas ?? document.createElement("canvas");
5559
+ const ctx = this.canvas.getContext("2d");
5560
+ if (!ctx) {
5561
+ throw new Error("Graphein: 2D canvas context is unavailable in this environment.");
5562
+ }
5563
+ this.ctx = ctx;
5564
+ }
5565
+ /** Resize the layer to a CSS size; updates the backing store + DPR transform. */
5566
+ resize(width, height, dpr = getDevicePixelRatio()) {
5567
+ const b = computeBackingSize(width, height, dpr);
5568
+ this.width = b.cssWidth;
5569
+ this.height = b.cssHeight;
5570
+ this.dpr = b.dpr;
5571
+ if (this.canvas.width !== b.pixelWidth) this.canvas.width = b.pixelWidth;
5572
+ if (this.canvas.height !== b.pixelHeight) this.canvas.height = b.pixelHeight;
5573
+ this.canvas.style.width = `${b.cssWidth}px`;
5574
+ this.canvas.style.height = `${b.cssHeight}px`;
5575
+ this.ctx.setTransform(b.dpr, 0, 0, b.dpr, 0, 0);
5576
+ }
5577
+ /** Clear the entire backing store regardless of the current transform. */
5578
+ clear() {
5579
+ const { ctx, canvas } = this;
5580
+ ctx.save();
5581
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
5582
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
5583
+ ctx.restore();
5584
+ }
5585
+ };
5586
+
5587
+ // src/render/surface.ts
5588
+ var Surface = class {
5589
+ constructor(container) {
5590
+ __publicField(this, "container");
5591
+ __publicField(this, "root");
5592
+ __publicField(this, "marks");
5593
+ __publicField(this, "interaction");
5594
+ __publicField(this, "overlay");
5595
+ /** Visually-hidden container for the screen-reader data-table fallback. */
5596
+ __publicField(this, "a11y");
5597
+ __publicField(this, "width", 0);
5598
+ __publicField(this, "height", 0);
5599
+ __publicField(this, "dpr", 1);
5600
+ /**
5601
+ * When true, overlay text (axis labels, titles, legends, annotation labels) is
5602
+ * painted onto the `marks` canvas instead of the HTML overlay. Set by headless
5603
+ * renderers (`@graphein/node`) where there is no DOM to host crisp text.
5604
+ * Defaults to false (browser).
5605
+ */
5606
+ __publicField(this, "headless", false);
5607
+ this.container = container;
5608
+ this.root = createDiv("graphein-root", {
5609
+ position: "relative",
5610
+ width: "100%",
5611
+ height: "100%",
5612
+ overflow: "hidden"
5613
+ });
5614
+ const marksCanvas = document.createElement("canvas");
5615
+ marksCanvas.className = "graphein-layer-marks";
5616
+ setStyle(marksCanvas, { ...fillParentStyle });
5617
+ const interactionCanvas = document.createElement("canvas");
5618
+ interactionCanvas.className = "graphein-layer-interaction";
5619
+ setStyle(interactionCanvas, { ...fillParentStyle, pointerEvents: "none" });
5620
+ this.overlay = createDiv("graphein-layer-overlay", {
5621
+ ...fillParentStyle,
5622
+ width: "100%",
5623
+ height: "100%",
5624
+ pointerEvents: "none"
5625
+ });
5626
+ this.a11y = createDiv("graphein-a11y", {
5627
+ position: "absolute",
5628
+ width: "1px",
5629
+ height: "1px",
5630
+ overflow: "hidden",
5631
+ clip: "rect(0 0 0 0)",
5632
+ clipPath: "inset(50%)",
5633
+ whiteSpace: "nowrap",
5634
+ border: "0",
5635
+ margin: "-1px",
5636
+ padding: "0"
5637
+ });
5638
+ this.root.appendChild(marksCanvas);
5639
+ this.root.appendChild(interactionCanvas);
5640
+ this.root.appendChild(this.overlay);
5641
+ this.root.appendChild(this.a11y);
5642
+ container.appendChild(this.root);
5643
+ this.marks = new CanvasLayer(marksCanvas);
5644
+ this.interaction = new CanvasLayer(interactionCanvas);
5645
+ }
5646
+ resize(width, height, dpr = getDevicePixelRatio()) {
5647
+ this.width = width;
5648
+ this.height = height;
5649
+ this.dpr = dpr;
5650
+ this.marks.resize(width, height, dpr);
5651
+ this.interaction.resize(width, height, dpr);
5652
+ setStyle(this.overlay, { width: `${width}px`, height: `${height}px` });
5653
+ }
5654
+ /** Clear both canvas layers and empty the HTML overlay. */
5655
+ clear() {
5656
+ this.marks.clear();
5657
+ this.interaction.clear();
5658
+ this.overlay.replaceChildren();
5659
+ }
5660
+ /** Remove all DOM created by this Surface. */
5661
+ destroy() {
5662
+ this.clear();
5663
+ this.root.remove();
5664
+ }
5665
+ };
5666
+
5667
+ // src/render/overlayText.ts
5668
+ function paintCanvasText(ctx, c) {
5669
+ const align = c.align ?? "left";
5670
+ const baseline = c.baseline ?? "top";
5671
+ ctx.save();
5672
+ if (c.opacity != null) ctx.globalAlpha *= c.opacity;
5673
+ if (c.rotate) {
5674
+ ctx.translate(c.x, c.y);
5675
+ ctx.rotate(c.rotate);
5676
+ ctx.translate(-c.x, -c.y);
5677
+ }
5678
+ ctx.font = c.font;
5679
+ ctx.textAlign = align;
5680
+ ctx.textBaseline = baseline;
5681
+ if (c.pill) {
5682
+ const padX = c.pill.padX ?? 7;
5683
+ const padY = c.pill.padY ?? 2;
5684
+ const tw = ctx.measureText(c.text).width;
5685
+ const th = c.size;
5686
+ let bx = c.x;
5687
+ if (align === "center") bx = c.x - tw / 2;
5688
+ else if (align === "right") bx = c.x - tw;
5689
+ let by = c.y;
5690
+ if (baseline === "middle") by = c.y - th / 2;
5691
+ else if (baseline === "alphabetic") by = c.y - th;
5692
+ ctx.beginPath();
5693
+ roundedRect(ctx, bx - padX, by - padY, tw + padX * 2, th + padY * 2, c.pill.radius ?? 999);
5694
+ ctx.fillStyle = c.pill.background;
5695
+ ctx.fill();
5696
+ if (c.pill.border) {
5697
+ ctx.lineWidth = 1;
5698
+ ctx.strokeStyle = c.pill.border;
5699
+ ctx.stroke();
5700
+ }
5701
+ }
5702
+ ctx.fillStyle = c.color;
5703
+ ctx.fillText(c.text, c.x, c.y);
5704
+ ctx.restore();
5705
+ }
5706
+ function overlayTextToCanvasCmd(o, font) {
5707
+ const t = o.transform ?? "";
5708
+ const rotate = t.includes("rotate(-90deg)") ? -Math.PI / 2 : t.includes("rotate(90deg)") ? Math.PI / 2 : (() => {
5709
+ const m = t.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
5710
+ return m ? parseFloat(m[1]) * Math.PI / 180 : void 0;
5711
+ })();
5712
+ let align = "left";
5713
+ let x = o.left;
5714
+ const y = o.top;
5715
+ if (o.width != null) {
5716
+ align = o.align ?? "left";
5717
+ x = align === "right" ? o.left + o.width : align === "center" ? o.left + o.width / 2 : o.left;
5718
+ } else if (t.includes("translateX(-100%)")) {
5719
+ align = "right";
5720
+ } else if (t.includes("translateX(-50%)") || t.includes("translate(-50%")) {
5721
+ align = "center";
5722
+ }
5723
+ const baseline = t.includes("translateY(-50%)") || t.includes(",-50%") || t.includes(", -50%") ? "middle" : "top";
5724
+ return {
5725
+ x,
5726
+ y,
5727
+ text: o.text,
5728
+ font,
5729
+ color: o.color,
5730
+ size: o.size,
5731
+ align,
5732
+ baseline,
5733
+ rotate,
5734
+ opacity: o.opacity,
5735
+ pill: o.pill
5736
+ };
5737
+ }
5738
+ function legendSwatchWidth(symbol, swatch = 11) {
5739
+ return symbol === "line" ? swatch + 3 : swatch;
5740
+ }
5741
+ function paintLegendSwatch(ctx, x, midY, symbol, color, swatch = 11) {
5742
+ ctx.save();
5743
+ ctx.fillStyle = color;
5744
+ ctx.beginPath();
5745
+ if (symbol === "circle") {
5746
+ ctx.arc(x + swatch / 2, midY, swatch / 2, 0, Math.PI * 2);
5747
+ } else if (symbol === "line") {
5748
+ roundedRect(ctx, x, midY - 1.5, swatch + 3, 3, 1.5);
5749
+ } else {
5750
+ roundedRect(ctx, x, midY - swatch / 2, swatch, swatch, 2);
5751
+ }
5752
+ ctx.fill();
5753
+ ctx.restore();
5754
+ }
5755
+
5756
+ // src/render/text.ts
5757
+ var measureCtx;
5758
+ var injectedCtx = null;
5759
+ function setMeasureContext(ctx) {
5760
+ injectedCtx = ctx;
5761
+ }
5762
+ function getMeasureCtx() {
5763
+ if (injectedCtx) return injectedCtx;
5764
+ if (measureCtx !== void 0) return measureCtx;
5765
+ try {
5766
+ measureCtx = document.createElement("canvas").getContext("2d");
5767
+ } catch {
5768
+ measureCtx = null;
5769
+ }
5770
+ return measureCtx;
5771
+ }
5772
+ function measureText(text, font) {
5773
+ const ctx = getMeasureCtx();
5774
+ if (!ctx) {
5775
+ const sizeMatch = /(\d+(?:\.\d+)?)px/.exec(font);
5776
+ const size = sizeMatch ? parseFloat(sizeMatch[1]) : 12;
5777
+ return { width: text.length * size * 0.55 };
5778
+ }
5779
+ ctx.font = font;
5780
+ return { width: ctx.measureText(text).width };
5781
+ }
5782
+ function fontString(size, family, weight = 400, style = "normal") {
5783
+ return `${style} ${weight} ${size}px ${family}`;
5784
+ }
5785
+ function ellipsize(text, maxWidth, font) {
5786
+ if (maxWidth <= 0 || text.length === 0) return "";
5787
+ if (measureText(text, font).width <= maxWidth) return text;
5788
+ const ellipsis = "\u2026";
5789
+ const ellipsisW = measureText(ellipsis, font).width;
5790
+ if (ellipsisW > maxWidth) return "";
5791
+ let lo = 0;
5792
+ let hi = text.length;
5793
+ while (lo < hi) {
5794
+ const mid = Math.ceil((lo + hi) / 2);
5795
+ if (measureText(text.slice(0, mid) + ellipsis, font).width <= maxWidth) lo = mid;
5796
+ else hi = mid - 1;
5797
+ }
5798
+ return text.slice(0, lo) + ellipsis;
5799
+ }
5800
+
5801
+ // src/theme/tokens.ts
5802
+ var PALETTE = categorical("graphein");
5803
+ var FONT_FAMILY = 'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
5804
+ var SHARED = {
5805
+ font: {
5806
+ family: FONT_FAMILY,
5807
+ size: { tiny: 10, small: 11, base: 12, large: 14, title: 16 },
5808
+ weight: { normal: 400, medium: 500, bold: 600 }
5809
+ },
5810
+ spacing: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 },
5811
+ radius: { sm: 4, md: 6, lg: 8 },
5812
+ stroke: { thin: 1, base: 1.5, thick: 2 }
5813
+ };
5814
+ var lightTheme = {
5815
+ name: "light",
5816
+ dark: false,
5817
+ color: {
5818
+ background: "#ffffff",
5819
+ surface: "#f8fafc",
5820
+ text: "#0f172a",
5821
+ textMuted: "#64748b",
5822
+ axis: "#cbd5e1",
5823
+ grid: "#eef2f6",
5824
+ border: "#e2e8f0",
5825
+ accent: "#0d9488",
5826
+ palette: PALETTE,
5827
+ positive: "#16a34a",
5828
+ negative: "#dc2626"
5829
+ },
5830
+ ...SHARED
5831
+ };
5832
+ var darkTheme = {
5833
+ name: "dark",
5834
+ dark: true,
5835
+ color: {
5836
+ background: "#0b1220",
5837
+ surface: "#111a2e",
5838
+ text: "#f1f5f9",
5839
+ textMuted: "#94a3b8",
5840
+ axis: "#334155",
5841
+ grid: "#1c2740",
5842
+ border: "#1e293b",
5843
+ accent: "#2dd4bf",
5844
+ palette: PALETTE,
5845
+ positive: "#22c55e",
5846
+ negative: "#f87171"
5847
+ },
5848
+ ...SHARED
5849
+ };
5850
+ var themes = {
5851
+ light: lightTheme,
5852
+ dark: darkTheme
5853
+ };
5854
+ function isPlainObject(v) {
5855
+ return typeof v === "object" && v !== null && !Array.isArray(v);
5856
+ }
5857
+ function deepMerge(base, override) {
5858
+ if (!isPlainObject(override)) return base;
5859
+ if (!isPlainObject(base)) return override;
5860
+ const out = { ...base };
5861
+ for (const [key, value] of Object.entries(override)) {
5862
+ const baseValue = out[key];
5863
+ if (isPlainObject(baseValue) && isPlainObject(value)) {
5864
+ out[key] = deepMerge(baseValue, value);
5865
+ } else {
5866
+ out[key] = value;
5867
+ }
5868
+ }
5869
+ return out;
5870
+ }
5871
+ function resolveTheme(theme) {
5872
+ if (theme == null) return lightTheme;
5873
+ if (typeof theme === "string") return themes[theme] ?? lightTheme;
5874
+ const baseName = theme.base ?? (theme.dark ? "dark" : "light");
5875
+ const base = themes[baseName] ?? lightTheme;
5876
+ const { base: _ignored, ...override } = theme;
5877
+ return deepMerge(base, override);
5878
+ }
5879
+
5880
+ // src/theme/sketch.ts
5881
+ var SKETCH_FONT_NAME = "Patrick Hand";
5882
+ var SKETCH_FONT_FAMILY = `'Patrick Hand', 'Segoe Print', 'Bradley Hand', 'Comic Sans MS', ui-rounded, cursive`;
5883
+ var injected = false;
5884
+ function injectViaStyle(dataUrl) {
5885
+ const style = document.createElement("style");
5886
+ style.setAttribute("data-graphein-sketch-font", "");
5887
+ style.textContent = `@font-face{font-family:'${SKETCH_FONT_NAME}';font-style:normal;font-weight:400;font-display:swap;src:url("${dataUrl}") format("woff2");}`;
5888
+ document.head.appendChild(style);
5889
+ }
5890
+ async function ensureSketchFont() {
5891
+ if (injected) return false;
5892
+ if (typeof document === "undefined") return false;
5893
+ injected = true;
5894
+ try {
5895
+ const { PATRICK_HAND_DATA_URL } = await import('./patrickHand-ITDQJJE2.js');
5896
+ const docFonts = document.fonts;
5897
+ if (typeof FontFace !== "undefined" && docFonts?.add) {
5898
+ const face = new FontFace(
5899
+ SKETCH_FONT_NAME,
5900
+ `url("${PATRICK_HAND_DATA_URL}") format("woff2")`,
5901
+ { style: "normal", weight: "400" }
5902
+ );
5903
+ await face.load();
5904
+ docFonts.add(face);
5905
+ return true;
5906
+ }
5907
+ injectViaStyle(PATRICK_HAND_DATA_URL);
5908
+ return true;
5909
+ } catch {
5910
+ injected = false;
5911
+ return false;
5912
+ }
5752
5913
  }
5753
- function assertValidSpec(spec) {
5754
- const { valid, errors } = validateSpec(spec);
5755
- if (!valid) {
5756
- const detail = errors.map((e) => ` - ${e.path || "(root)"}: ${e.message}`).join("\n");
5757
- throw new Error(`Invalid Graphein chart spec:
5758
- ${detail}`);
5914
+ function withSketchFont(tokens) {
5915
+ return {
5916
+ ...tokens,
5917
+ font: {
5918
+ ...tokens.font,
5919
+ family: `${SKETCH_FONT_FAMILY}`
5920
+ }
5921
+ };
5922
+ }
5923
+
5924
+ // src/spec/infer.ts
5925
+ function looksTemporal2(value) {
5926
+ if (value.length < 4) return false;
5927
+ if (/^\d{4}(-\d{2}(-\d{2})?)?([T\s]\d{2}:\d{2})?/.test(value)) {
5928
+ const t = Date.parse(value);
5929
+ return !Number.isNaN(t);
5759
5930
  }
5760
- return spec;
5931
+ return false;
5932
+ }
5933
+ function inferValueType(value) {
5934
+ if (value == null) return void 0;
5935
+ if (typeof value === "number") return Number.isFinite(value) ? "quantitative" : void 0;
5936
+ if (value instanceof Date) return "temporal";
5937
+ if (typeof value === "boolean") return "nominal";
5938
+ if (typeof value === "string") {
5939
+ if (value.trim() === "") return void 0;
5940
+ if (looksTemporal2(value)) return "temporal";
5941
+ if (/^-?\d*\.?\d+(e[-+]?\d+)?$/i.test(value.trim())) return "quantitative";
5942
+ return "nominal";
5943
+ }
5944
+ return "nominal";
5945
+ }
5946
+ function inferFieldType(data, field, sample = 50) {
5947
+ let seen = 0;
5948
+ const counts = {
5949
+ quantitative: 0,
5950
+ temporal: 0,
5951
+ ordinal: 0,
5952
+ nominal: 0
5953
+ };
5954
+ for (let i = 0; i < data.length && seen < sample; i++) {
5955
+ const t = inferValueType(data[i]?.[field]);
5956
+ if (!t) continue;
5957
+ counts[t]++;
5958
+ seen++;
5959
+ }
5960
+ if (seen === 0) return "nominal";
5961
+ if (counts.temporal >= seen * 0.6) return "temporal";
5962
+ if (counts.quantitative >= seen * 0.6) return "quantitative";
5963
+ return "nominal";
5964
+ }
5965
+ function inferFieldTypes(data) {
5966
+ const fields = /* @__PURE__ */ new Set();
5967
+ for (let i = 0; i < Math.min(data.length, 20); i++) {
5968
+ const row = data[i];
5969
+ if (row) for (const k of Object.keys(row)) fields.add(k);
5970
+ }
5971
+ const out = {};
5972
+ for (const f of fields) out[f] = inferFieldType(data, f);
5973
+ return out;
5761
5974
  }
5762
5975
 
5763
5976
  // src/spec/repair.ts
@@ -5784,10 +5997,10 @@ function repairSpec(spec) {
5784
5997
 
5785
5998
  // src/spec/sketch.ts
5786
5999
  function deriveSeed(spec) {
5787
- const title = typeof spec.title === "string" ? spec.title : spec.title?.text ?? spec.title?.subtitle ?? "";
6000
+ const title2 = typeof spec.title === "string" ? spec.title : spec.title?.text ?? spec.title?.subtitle ?? "";
5788
6001
  const dataLen = Array.isArray(spec.data) ? spec.data.length : 0;
5789
6002
  const firstRow = Array.isArray(spec.data) && spec.data[0] ? Object.keys(spec.data[0]).join(",") : "";
5790
- return hashString(`${spec.type}|${title}|${dataLen}|${firstRow}`) || 1;
6003
+ return hashString(`${spec.type}|${title2}|${dataLen}|${firstRow}`) || 1;
5791
6004
  }
5792
6005
  function resolveSketch(spec) {
5793
6006
  const sketch = spec.sketch;
@@ -6032,6 +6245,10 @@ function yGridEnabled(model) {
6032
6245
  return model.spec.axes?.y?.grid !== false;
6033
6246
  }
6034
6247
  function drawAxesUnderlay(surface, model) {
6248
+ if (model.orientation === "horizontal") {
6249
+ drawHorizontalAxesUnderlay(surface, model);
6250
+ return;
6251
+ }
6035
6252
  const ctx = surface.marks.ctx;
6036
6253
  const { plot, tokens } = model;
6037
6254
  const x0 = plot.x;
@@ -6105,6 +6322,58 @@ function drawAxesUnderlay(surface, model) {
6105
6322
  }
6106
6323
  ctx.restore();
6107
6324
  }
6325
+ function drawHorizontalAxesUnderlay(surface, model) {
6326
+ const ctx = surface.marks.ctx;
6327
+ const { plot, tokens } = model;
6328
+ const x0 = plot.x;
6329
+ const y0 = plot.y;
6330
+ const y1 = plot.y + plot.height;
6331
+ ctx.save();
6332
+ ctx.lineWidth = 1;
6333
+ const pen = model.sketch ? new RoughPen(ctx, model.sketch) : null;
6334
+ const seg = (sx0, sy0, sx1, sy1, stroke) => {
6335
+ pen.polyline([{ x: sx0, y: sy0 }, { x: sx1, y: sy1 }], {
6336
+ stroke,
6337
+ strokeWidth: 1,
6338
+ roughness: model.sketch.roughness * 0.5,
6339
+ bowing: model.sketch.bowing * 0.55
6340
+ });
6341
+ };
6342
+ if (yGridEnabled(model)) {
6343
+ if (pen) {
6344
+ for (const t of model.yTicks) seg(t.pos, y0, t.pos, y1, tokens.color.grid);
6345
+ } else {
6346
+ ctx.strokeStyle = tokens.color.grid;
6347
+ ctx.beginPath();
6348
+ for (const t of model.yTicks) {
6349
+ const x = crisp(t.pos);
6350
+ ctx.moveTo(x, y0);
6351
+ ctx.lineTo(x, y1);
6352
+ }
6353
+ ctx.stroke();
6354
+ }
6355
+ }
6356
+ if (model.spec.axes?.x?.show !== false) {
6357
+ if (pen) {
6358
+ seg(x0, y0, x0, y1, tokens.color.axis);
6359
+ for (const t of model.xTicks) seg(x0 - TICK_SIZE, t.pos, x0, t.pos, tokens.color.axis);
6360
+ } else {
6361
+ ctx.strokeStyle = tokens.color.axis;
6362
+ ctx.beginPath();
6363
+ ctx.moveTo(crisp(x0), y0);
6364
+ ctx.lineTo(crisp(x0), y1);
6365
+ ctx.stroke();
6366
+ ctx.beginPath();
6367
+ for (const t of model.xTicks) {
6368
+ const y = crisp(t.pos);
6369
+ ctx.moveTo(x0 - TICK_SIZE, y);
6370
+ ctx.lineTo(x0, y);
6371
+ }
6372
+ ctx.stroke();
6373
+ }
6374
+ }
6375
+ ctx.restore();
6376
+ }
6108
6377
  function thinXTicks(model, avgCharPx) {
6109
6378
  const ticks2 = model.xTicks;
6110
6379
  if (ticks2.length <= 1) return ticks2;
@@ -6175,9 +6444,12 @@ function drawOverlay(surface, model) {
6175
6444
  const { plot, tokens, frame } = model;
6176
6445
  const f = tokens.font;
6177
6446
  const smallFont = fontString(f.size.small, f.family, f.weight.normal);
6178
- if (model.spec.axes?.y?.labels !== false) {
6447
+ const horizontal = model.orientation === "horizontal";
6448
+ const gutterTicks = horizontal ? model.xTicks : model.yTicks;
6449
+ const gutterLabelsOn = horizontal ? model.spec.axes?.x?.labels !== false : model.spec.axes?.y?.labels !== false;
6450
+ if (gutterLabelsOn) {
6179
6451
  const gutterRight = plot.x - TICK_SIZE - 4;
6180
- for (const t of model.yTicks) {
6452
+ for (const t of gutterTicks) {
6181
6453
  addText(surface, smallFont, {
6182
6454
  left: 0,
6183
6455
  top: t.pos,
@@ -6190,20 +6462,34 @@ function drawOverlay(surface, model) {
6190
6462
  });
6191
6463
  }
6192
6464
  }
6193
- if (model.spec.axes?.x?.labels !== false) {
6465
+ const bottomLabelsOn = horizontal ? model.spec.axes?.y?.labels !== false : model.spec.axes?.x?.labels !== false;
6466
+ if (bottomLabelsOn) {
6194
6467
  const top = plot.y + plot.height + TICK_SIZE + 3;
6195
- const rotated = frame.xLabelAngle > 0;
6196
- for (const p of resolveXLabels(model)) {
6197
- addText(surface, smallFont, {
6198
- left: p.left,
6199
- top,
6200
- text: p.text,
6201
- color: tokens.color.textMuted,
6202
- size: f.size.small,
6203
- align: rotated ? "right" : void 0,
6204
- transform: p.transform,
6205
- transformOrigin: rotated ? "100% 0%" : void 0
6206
- });
6468
+ if (horizontal) {
6469
+ for (const p of placeXLabels(model.yTicks, frame.originX, frame.width, smallFont)) {
6470
+ addText(surface, smallFont, {
6471
+ left: p.left,
6472
+ top,
6473
+ text: p.text,
6474
+ color: tokens.color.textMuted,
6475
+ size: f.size.small,
6476
+ transform: p.transform
6477
+ });
6478
+ }
6479
+ } else {
6480
+ const rotated = frame.xLabelAngle > 0;
6481
+ for (const p of resolveXLabels(model)) {
6482
+ addText(surface, smallFont, {
6483
+ left: p.left,
6484
+ top,
6485
+ text: p.text,
6486
+ color: tokens.color.textMuted,
6487
+ size: f.size.small,
6488
+ align: rotated ? "right" : void 0,
6489
+ transform: p.transform,
6490
+ transformOrigin: rotated ? "100% 0%" : void 0
6491
+ });
6492
+ }
6207
6493
  }
6208
6494
  }
6209
6495
  drawAxisTitles(surface, model);
@@ -6215,6 +6501,32 @@ function drawAxisTitles(surface, model) {
6215
6501
  const f = tokens.font;
6216
6502
  const baseFont = fontString(f.size.base, f.family, f.weight.medium);
6217
6503
  const xTitle = model.spec.axes?.x?.title ?? model.spec.encoding.x.title;
6504
+ const yTitle = model.spec.axes?.y?.title ?? model.spec.encoding.y.title;
6505
+ if (model.orientation === "horizontal") {
6506
+ if (yTitle) {
6507
+ addText(surface, baseFont, {
6508
+ left: plot.x + plot.width / 2,
6509
+ top: frame.originY + frame.height - Math.round(f.size.base * 1.35),
6510
+ text: yTitle,
6511
+ color: tokens.color.text,
6512
+ size: f.size.base,
6513
+ weight: f.weight.medium,
6514
+ transform: "translateX(-50%)"
6515
+ });
6516
+ }
6517
+ if (xTitle) {
6518
+ addText(surface, baseFont, {
6519
+ left: frame.originX + Math.round(f.size.base * 0.4),
6520
+ top: plot.y + plot.height / 2,
6521
+ text: xTitle,
6522
+ color: tokens.color.text,
6523
+ size: f.size.base,
6524
+ weight: f.weight.medium,
6525
+ transform: "translate(-50%, -50%) rotate(-90deg)"
6526
+ });
6527
+ }
6528
+ return;
6529
+ }
6218
6530
  if (xTitle) {
6219
6531
  addText(surface, baseFont, {
6220
6532
  left: plot.x + plot.width / 2,
@@ -6226,7 +6538,6 @@ function drawAxisTitles(surface, model) {
6226
6538
  transform: "translateX(-50%)"
6227
6539
  });
6228
6540
  }
6229
- const yTitle = model.spec.axes?.y?.title ?? model.spec.encoding.y.title;
6230
6541
  if (yTitle) {
6231
6542
  addText(surface, baseFont, {
6232
6543
  left: frame.originX + Math.round(f.size.base * 0.4),
@@ -6242,25 +6553,25 @@ function drawAxisTitles(surface, model) {
6242
6553
  function drawTitle(surface, frame, spec, tokens) {
6243
6554
  if (!frame.titleRect) return;
6244
6555
  const f = tokens.font;
6245
- const title = typeof spec.title === "string" ? { text: spec.title } : spec.title ?? {};
6556
+ const title2 = typeof spec.title === "string" ? { text: spec.title } : spec.title ?? {};
6246
6557
  const align = typeof spec.title === "object" && spec.title?.align || "left";
6247
6558
  const titleFont = fontString(f.size.title, f.family, f.weight.bold);
6248
6559
  addText(surface, titleFont, {
6249
6560
  left: frame.titleRect.x,
6250
6561
  top: frame.titleRect.y,
6251
6562
  width: frame.titleRect.width,
6252
- text: title.text ?? "",
6563
+ text: title2.text ?? "",
6253
6564
  color: tokens.color.text,
6254
6565
  size: f.size.title,
6255
6566
  weight: f.weight.bold,
6256
6567
  align
6257
6568
  });
6258
- if (frame.subtitleRect && title.subtitle) {
6569
+ if (frame.subtitleRect && title2.subtitle) {
6259
6570
  addText(surface, fontString(f.size.small, f.family, f.weight.normal), {
6260
6571
  left: frame.subtitleRect.x,
6261
6572
  top: frame.subtitleRect.y,
6262
6573
  width: frame.subtitleRect.width,
6263
- text: title.subtitle,
6574
+ text: title2.subtitle,
6264
6575
  color: tokens.color.textMuted,
6265
6576
  size: f.size.small,
6266
6577
  align
@@ -6418,6 +6729,10 @@ function formatNumericTick(value, userFormat) {
6418
6729
  return formatNumber2(value, ",");
6419
6730
  }
6420
6731
  var DEFAULT_PADDING = { top: 12, right: 16, bottom: 12, left: 12 };
6732
+ var LEGEND_SWATCH2 = 11;
6733
+ function legendInteractive(spec) {
6734
+ return typeof spec.legend === "object" && spec.legend.interactive === true;
6735
+ }
6421
6736
  function buildCartesianModel(spec, tokens, opts) {
6422
6737
  const data = spec.data ?? [];
6423
6738
  const enc = spec.encoding;
@@ -6425,10 +6740,11 @@ function buildCartesianModel(spec, tokens, opts) {
6425
6740
  const yField = enc.y.field;
6426
6741
  const xType = fieldType(spec, enc.x, "nominal");
6427
6742
  const xKind = xKindFor(spec, xType);
6743
+ const horizontal = spec.type === "bar" && spec.orientation === "horizontal";
6428
6744
  const seriesField = resolveSeriesField(spec);
6429
6745
  const rawSeries = groupBySeries(data, seriesField);
6430
6746
  const seriesKeys = rawSeries.map((s) => s.key);
6431
- const palette = tokens.color.palette;
6747
+ const palette = Array.isArray(spec.palette) ? spec.palette : typeof spec.palette === "string" ? categorical(spec.palette) : tokens.color.palette;
6432
6748
  const colorScale = ordinalColorScale({ domain: seriesKeys, palette });
6433
6749
  const colorCache = /* @__PURE__ */ new Map();
6434
6750
  const sharedColor = opts.shared?.colorOf;
@@ -6580,6 +6896,19 @@ function buildCartesianModel(spec, tokens, opts) {
6580
6896
  }));
6581
6897
  const padding = { ...DEFAULT_PADDING, ...spec.padding };
6582
6898
  const titleInput4 = resolveTitle(spec);
6899
+ const categoryAxisInput = {
6900
+ show: xShow,
6901
+ labels: xLabels,
6902
+ title: resolveAxisTitle(enc.x, xAxisCfg),
6903
+ edgeAnchored: !categories,
6904
+ labelAngle: xAxisCfg.labelAngle
6905
+ };
6906
+ const valueAxisInput = {
6907
+ show: yShow,
6908
+ labels: yLabels,
6909
+ title: resolveAxisTitle(enc.y, yAxisCfg),
6910
+ edgeAnchored: true
6911
+ };
6583
6912
  const frame = computeFrame({
6584
6913
  width: opts.width,
6585
6914
  height: opts.height,
@@ -6589,20 +6918,37 @@ function buildCartesianModel(spec, tokens, opts) {
6589
6918
  font: tokens.font,
6590
6919
  title: titleInput4,
6591
6920
  legend: legend.show && legendItems.length > 1 ? { items: legendItems, position: legend.position } : void 0,
6592
- xAxis: { show: xShow, labels: xLabels, title: resolveAxisTitle(enc.x, xAxisCfg), edgeAnchored: !categories, labelAngle: xAxisCfg.labelAngle },
6593
- yAxis: { show: yShow, labels: yLabels, title: resolveAxisTitle(enc.y, yAxisCfg) }
6921
+ xAxis: horizontal ? valueAxisInput : categoryAxisInput,
6922
+ yAxis: horizontal ? categoryAxisInput : valueAxisInput
6594
6923
  });
6924
+ const legendHits = legendInteractive(spec) && seriesField && frame.legendItems ? frame.legendItems.map((item, i) => {
6925
+ const rowH = Math.max(LEGEND_SWATCH2, Math.round(tokens.font.size.small * 1.35));
6926
+ const midY = item.y + LEGEND_SWATCH2 / 2;
6927
+ return {
6928
+ rect: {
6929
+ x: item.x,
6930
+ y: midY - rowH / 2,
6931
+ width: item.width,
6932
+ height: rowH
6933
+ },
6934
+ key: series[i]?.key ?? item.label,
6935
+ value: series[i]?.value ?? item.label,
6936
+ label: item.label
6937
+ };
6938
+ }) : void 0;
6595
6939
  const plot = frame.plot;
6596
- const yRange = [plot.y + plot.height, plot.y];
6597
- const yScale = yIsLog ? logScale({ domain: yDomain, range: yRange, base: yBase, clamp: yClamp }) : linearScale({ domain: yDomain, range: yRange, clamp: yClamp });
6940
+ const valueRange = horizontal ? [plot.x, plot.x + plot.width] : [plot.y + plot.height, plot.y];
6941
+ const yScale = yIsLog ? logScale({ domain: yDomain, range: valueRange, base: yBase, clamp: yClamp }) : linearScale({ domain: yDomain, range: valueRange, clamp: yClamp });
6598
6942
  const yPixel = (value) => yScale.map(toNumber(value));
6599
- const baseline = yIsLog ? plot.y + plot.height : clampPx(yScale.map(0), plot.y, plot.y + plot.height);
6943
+ const valueMin = horizontal ? plot.x : plot.y;
6944
+ const valueMax = horizontal ? plot.x + plot.width : plot.y + plot.height;
6945
+ const baseline = yIsLog ? horizontal ? plot.x : plot.y + plot.height : clampPx(yScale.map(0), valueMin, valueMax);
6600
6946
  const xModel = buildXModel(xKind, {
6601
6947
  field: xField,
6602
6948
  type: xType,
6603
6949
  categories,
6604
6950
  domainNum: xDomainNum,
6605
- range: [plot.x, plot.x + plot.width],
6951
+ range: horizontal ? [plot.y, plot.y + plot.height] : [plot.x, plot.x + plot.width],
6606
6952
  paddingInner: spec.type === "bar" ? 0.2 : spec.type === "box" ? 0.32 : 0.5,
6607
6953
  scaleCfg: enc.x.scale
6608
6954
  });
@@ -6625,9 +6971,12 @@ function buildCartesianModel(spec, tokens, opts) {
6625
6971
  series,
6626
6972
  seriesField,
6627
6973
  stacked,
6974
+ orientation: horizontal ? "horizontal" : "vertical",
6975
+ project: horizontal ? (catPixel, valPixel) => ({ x: valPixel, y: catPixel }) : (catPixel, valPixel) => ({ x: catPixel, y: valPixel }),
6628
6976
  xTicks,
6629
6977
  yTicks,
6630
6978
  colorOf,
6979
+ legendHits,
6631
6980
  sketch: resolveSketch(spec)
6632
6981
  };
6633
6982
  }
@@ -7021,14 +7370,11 @@ function finite2(value) {
7021
7370
  function defaultRadius(model, spec) {
7022
7371
  return Math.max(0, spec.cornerRadius ?? Math.min(4, model.tokens.radius.sm));
7023
7372
  }
7024
- function cornerSideFor(baseY, valueY) {
7025
- if (valueY < baseY) return "top";
7026
- if (valueY > baseY) return "bottom";
7027
- return "none";
7028
- }
7029
7373
  function makeRadii(side, radius) {
7030
7374
  if (side === "top") return [radius, radius, 0, 0];
7031
7375
  if (side === "bottom") return [0, 0, radius, radius];
7376
+ if (side === "right") return [0, radius, radius, 0];
7377
+ if (side === "left") return [radius, 0, 0, radius];
7032
7378
  return 0;
7033
7379
  }
7034
7380
  function drawRect(ctx, rect, radius, pen, alpha = 1) {
@@ -7048,28 +7394,39 @@ function drawRect(ctx, rect, radius, pen, alpha = 1) {
7048
7394
  ctx.fill();
7049
7395
  if (alpha !== 1) ctx.globalAlpha = prevAlpha;
7050
7396
  }
7051
- function barRect(x, width, baseY, valueY, color) {
7052
- if (!finite2(x) || !finite2(width) || !finite2(baseY) || !finite2(valueY)) return null;
7053
- const y = Math.min(baseY, valueY);
7054
- const height = Math.abs(valueY - baseY);
7055
- if (width <= 0 || height <= 0) return null;
7397
+ function orientedRect(horizontal, catStart, catThick, base, value, color) {
7398
+ if (!finite2(catStart) || !finite2(catThick) || !finite2(base) || !finite2(value)) return null;
7399
+ const lo = Math.min(base, value);
7400
+ const len = Math.abs(value - base);
7401
+ if (catThick <= 0 || len <= 0) return null;
7402
+ if (horizontal) {
7403
+ return {
7404
+ x: lo,
7405
+ y: catStart,
7406
+ width: len,
7407
+ height: catThick,
7408
+ color,
7409
+ cornerSide: value > base ? "right" : value < base ? "left" : "none"
7410
+ };
7411
+ }
7056
7412
  return {
7057
- x,
7058
- y,
7059
- width,
7060
- height,
7413
+ x: catStart,
7414
+ y: lo,
7415
+ width: catThick,
7416
+ height: len,
7061
7417
  color,
7062
- cornerSide: cornerSideFor(baseY, valueY)
7418
+ cornerSide: value < base ? "top" : value > base ? "bottom" : "none"
7063
7419
  };
7064
7420
  }
7065
7421
  function drawGroupedBars(ctx, model, radius, pen) {
7066
7422
  const readX = accessor(model.x.field);
7067
7423
  const readY = accessor(model.y.field ?? "");
7424
+ const horizontal = model.orientation === "horizontal";
7068
7425
  const seriesCount = model.series.length;
7069
7426
  const bandWidth = model.x.bandwidth;
7070
7427
  const subWidth = seriesCount > 1 ? bandWidth / seriesCount : bandWidth;
7071
7428
  const gap = seriesCount > 1 && subWidth > 3 ? Math.min(1, subWidth * 0.15) : 0;
7072
- const barWidth = Math.max(0, subWidth - gap);
7429
+ const barThick = Math.max(0, subWidth - gap);
7073
7430
  model.series.forEach((series, seriesIndex) => {
7074
7431
  ctx.fillStyle = series.color;
7075
7432
  for (const row of series.rows) {
@@ -7078,8 +7435,8 @@ function drawGroupedBars(ctx, model, radius, pen) {
7078
7435
  if (raw == null || raw === "" || !finite2(value)) continue;
7079
7436
  const center2 = model.x.pixel(readX(row));
7080
7437
  if (center2 == null) continue;
7081
- const left = center2 - bandWidth / 2 + seriesIndex * subWidth + gap / 2;
7082
- const rect = barRect(left, barWidth, model.y.baseline, model.y.pixel(value), series.color);
7438
+ const catStart = center2 - bandWidth / 2 + seriesIndex * subWidth + gap / 2;
7439
+ const rect = orientedRect(horizontal, catStart, barThick, model.y.baseline, model.y.pixel(value), series.color);
7083
7440
  if (rect) drawRect(ctx, rect, radius, pen, rowAlpha(model.emphasis, row));
7084
7441
  }
7085
7442
  });
@@ -7087,6 +7444,7 @@ function drawGroupedBars(ctx, model, radius, pen) {
7087
7444
  function drawStackedBars(ctx, model, radius, pen) {
7088
7445
  const readX = accessor(model.x.field);
7089
7446
  const readY = accessor(model.y.field ?? "");
7447
+ const horizontal = model.orientation === "horizontal";
7090
7448
  const positiveTotals = /* @__PURE__ */ new Map();
7091
7449
  const negativeTotals = /* @__PURE__ */ new Map();
7092
7450
  const segments = [];
@@ -7106,7 +7464,7 @@ function drawStackedBars(ctx, model, radius, pen) {
7106
7464
  const base = totals.get(categoryKey2) ?? 0;
7107
7465
  const top = base + value;
7108
7466
  totals.set(categoryKey2, top);
7109
- const rect = barRect(center2 - bandWidth / 2, bandWidth, model.y.pixel(base), model.y.pixel(top), series.color);
7467
+ const rect = orientedRect(horizontal, center2 - bandWidth / 2, bandWidth, model.y.pixel(base), model.y.pixel(top), series.color);
7110
7468
  if (!rect) continue;
7111
7469
  segments.push({ ...rect, stackEndKey, alpha: rowAlpha(model.emphasis, row) });
7112
7470
  roundedEnds.set(stackEndKey, segments.length - 1);
@@ -7137,6 +7495,8 @@ function drawBar(surface, model) {
7137
7495
  }
7138
7496
 
7139
7497
  // src/charts/scatter.ts
7498
+ var QUANTIZE_MIN_POINTS = 4e3;
7499
+ var QUANTIZE_AREA_DIVISOR = 4;
7140
7500
  function isMissing(value) {
7141
7501
  return value == null || value === "";
7142
7502
  }
@@ -7146,6 +7506,35 @@ function isInvalidPositionValue(value) {
7146
7506
  function fixedRadius() {
7147
7507
  return 3.75;
7148
7508
  }
7509
+ function shouldQuantize(count, plot, hasSize) {
7510
+ return !hasSize && count > QUANTIZE_MIN_POINTS && count > plot.width * plot.height / QUANTIZE_AREA_DIVISOR;
7511
+ }
7512
+ function quantizePoints(points) {
7513
+ if (points.length === 0) return [];
7514
+ let minX = Infinity;
7515
+ let maxX = -Infinity;
7516
+ let minY = Infinity;
7517
+ let maxY = -Infinity;
7518
+ for (const p of points) {
7519
+ if (p.x < minX) minX = p.x;
7520
+ if (p.x > maxX) maxX = p.x;
7521
+ if (p.y < minY) minY = p.y;
7522
+ if (p.y > maxY) maxY = p.y;
7523
+ }
7524
+ const seen = /* @__PURE__ */ new Set();
7525
+ const keep = [];
7526
+ for (const p of points) {
7527
+ const extentPoint = p.x === minX || p.x === maxX || p.y === minY || p.y === maxY;
7528
+ const ix = Math.round(p.x);
7529
+ const iy = Math.round(p.y);
7530
+ const key = `${p.seriesKey}|${ix}|${iy}`;
7531
+ if (extentPoint || !seen.has(key)) {
7532
+ keep.push(p);
7533
+ seen.add(key);
7534
+ }
7535
+ }
7536
+ return keep;
7537
+ }
7149
7538
  function radiusScale(rows, field, plotSize) {
7150
7539
  const rMin = 3;
7151
7540
  const rMax = clamp(plotSize * 0.05, 10, 26);
@@ -7188,18 +7577,22 @@ function drawScatter(surface, model) {
7188
7577
  y: py,
7189
7578
  r: readSize ? radius(readSize(row)) : radius(),
7190
7579
  color: series.color,
7191
- alpha: rowAlpha(model.emphasis, row)
7580
+ alpha: rowAlpha(model.emphasis, row),
7581
+ seriesKey: series.key
7192
7582
  });
7193
7583
  }
7194
7584
  }
7195
- if (sizeField) points.sort((a, b) => b.r - a.r);
7585
+ const originalCount = points.length;
7586
+ const drawPoints = shouldQuantize(originalCount, plot, !!sizeField) ? quantizePoints(points) : points;
7587
+ model.markStats = { drawn: drawPoints.length, original: originalCount };
7588
+ if (sizeField) drawPoints.sort((a, b) => b.r - a.r);
7196
7589
  ctx.save();
7197
7590
  ctx.beginPath();
7198
7591
  ctx.rect(plot.x, plot.y, plot.width, plot.height);
7199
7592
  ctx.clip();
7200
7593
  if (model.sketch) {
7201
7594
  const pen = new RoughPen(ctx, model.sketch);
7202
- for (const point of points) {
7595
+ for (const point of drawPoints) {
7203
7596
  const prev = ctx.globalAlpha;
7204
7597
  if (point.alpha !== 1) ctx.globalAlpha = point.alpha;
7205
7598
  pen.circle(point.x, point.y, point.r, {
@@ -7215,7 +7608,7 @@ function drawScatter(surface, model) {
7215
7608
  }
7216
7609
  ctx.lineWidth = 1;
7217
7610
  ctx.strokeStyle = tokens.color.background;
7218
- for (const point of points) {
7611
+ for (const point of drawPoints) {
7219
7612
  ctx.beginPath();
7220
7613
  ctx.arc(point.x, point.y, point.r, 0, Math.PI * 2);
7221
7614
  ctx.fillStyle = point.color;
@@ -7473,10 +7866,10 @@ function buildBoxInteraction(model) {
7473
7866
 
7474
7867
  // src/charts/chrome.ts
7475
7868
  var CHROME_PAD = { top: 14, right: 16, bottom: 14, left: 16 };
7476
- function resolveTitle2(title) {
7477
- if (typeof title === "string") return { text: title, align: "left" };
7478
- if (title && typeof title === "object") {
7479
- const t = title;
7869
+ function resolveTitle2(title2) {
7870
+ if (typeof title2 === "string") return { text: title2, align: "left" };
7871
+ if (title2 && typeof title2 === "object") {
7872
+ const t = title2;
7480
7873
  return { text: t.text, subtitle: t.subtitle, align: t.align ?? "left" };
7481
7874
  }
7482
7875
  return { align: "left" };
@@ -7531,9 +7924,9 @@ function addOverlayText(surface, tokens, o) {
7531
7924
  surface.overlay.appendChild(el);
7532
7925
  return el;
7533
7926
  }
7534
- function drawTitleBlock(surface, tokens, size, title) {
7927
+ function drawTitleBlock(surface, tokens, size, title2) {
7535
7928
  const f = tokens.font;
7536
- const t = resolveTitle2(title);
7929
+ const t = resolveTitle2(title2);
7537
7930
  let top = CHROME_PAD.top;
7538
7931
  const innerWidth = size.width - CHROME_PAD.left - CHROME_PAD.right;
7539
7932
  if (t.text) {
@@ -9912,11 +10305,11 @@ function buildHeader(opts) {
9912
10305
  thead.appendChild(tr);
9913
10306
  return thead;
9914
10307
  }
9915
- function makeHeaderCell(opts, title, colIndex, align, rowIndex = 0) {
10308
+ function makeHeaderCell(opts, title2, colIndex, align, rowIndex = 0) {
9916
10309
  const th = document.createElement("th");
9917
10310
  const metrics = densityMetrics(opts.density);
9918
10311
  th.scope = "col";
9919
- th.textContent = title;
10312
+ th.textContent = title2;
9920
10313
  const isSorted = colIndex != null && opts.sortState?.col === colIndex;
9921
10314
  if (isSorted) th.setAttribute("aria-sort", opts.sortState?.dir === "desc" ? "descending" : "ascending");
9922
10315
  setCellBaseStyles(th, opts.tokens, align ?? "left", opts.stickyHeader !== false, metrics.padding);
@@ -9948,7 +10341,7 @@ function makeHeaderCell(opts, title, colIndex, align, rowIndex = 0) {
9948
10341
  font: "inherit"
9949
10342
  });
9950
10343
  const label = document.createElement("span");
9951
- label.textContent = title;
10344
+ label.textContent = title2;
9952
10345
  label.style.overflow = "hidden";
9953
10346
  label.style.textOverflow = "ellipsis";
9954
10347
  const glyph = document.createElement("span");
@@ -10777,10 +11170,10 @@ function prepareMatrix(spec) {
10777
11170
  },
10778
11171
  ...leafValueColumns.map(({ leaf, valueIndex }) => {
10779
11172
  const valueDef = spec.values[valueIndex];
10780
- const title = spec.values.length > 1 ? valueDef.label ?? valueDef.field : leaf.key;
11173
+ const title2 = spec.values.length > 1 ? valueDef.label ?? valueDef.field : leaf.key;
10781
11174
  return {
10782
11175
  key: `${leaf.path.join(PATH_SEPARATOR2)}:${valueIndex}`,
10783
- title,
11176
+ title: title2,
10784
11177
  align: "right",
10785
11178
  type: "quantitative",
10786
11179
  format: valueDef.showAs && valueDef.showAs !== "value" ? ".1%" : valueDef.format,
@@ -10978,8 +11371,8 @@ function mountSlicerShell(surface, tokens, size, opts = {}) {
10978
11371
  color: c.text,
10979
11372
  font: fontString(tokens.font.size.base, tokens.font.family, tokens.font.weight.normal)
10980
11373
  });
10981
- const title = resolveTitle2(opts.title);
10982
- const labelText2 = title.text ?? opts.label;
11374
+ const title2 = resolveTitle2(opts.title);
11375
+ const labelText2 = title2.text ?? opts.label;
10983
11376
  const header = document.createElement("div");
10984
11377
  Object.assign(header.style, {
10985
11378
  display: "flex",
@@ -10993,16 +11386,16 @@ function mountSlicerShell(surface, tokens, size, opts = {}) {
10993
11386
  labelEl.textContent = labelText2 ?? "";
10994
11387
  Object.assign(labelEl.style, {
10995
11388
  font: fontString(
10996
- title.text ? tokens.font.size.large : tokens.font.size.small,
11389
+ title2.text ? tokens.font.size.large : tokens.font.size.small,
10997
11390
  tokens.font.family,
10998
- title.text ? tokens.font.weight.bold : tokens.font.weight.medium
11391
+ title2.text ? tokens.font.weight.bold : tokens.font.weight.medium
10999
11392
  ),
11000
- color: title.text ? c.text : c.textMuted,
11393
+ color: title2.text ? c.text : c.textMuted,
11001
11394
  whiteSpace: "nowrap",
11002
11395
  overflow: "hidden",
11003
11396
  textOverflow: "ellipsis"
11004
11397
  });
11005
- if (title.subtitle) labelEl.title = title.subtitle;
11398
+ if (title2.subtitle) labelEl.title = title2.subtitle;
11006
11399
  const clearSlot = document.createElement("div");
11007
11400
  clearSlot.style.flex = "0 0 auto";
11008
11401
  header.appendChild(labelEl);
@@ -11104,8 +11497,8 @@ function makePopover(tokens) {
11104
11497
  const c = tokens.color;
11105
11498
  const pop = document.createElement("div");
11106
11499
  Object.assign(pop.style, {
11107
- position: "absolute",
11108
- zIndex: "20",
11500
+ position: "fixed",
11501
+ zIndex: "2147483000",
11109
11502
  background: c.surface,
11110
11503
  border: `1px solid ${c.border}`,
11111
11504
  borderRadius: `${tokens.radius.md}px`,
@@ -11322,14 +11715,26 @@ function makeDualSlider(tokens, opts) {
11322
11715
  }
11323
11716
  };
11324
11717
  }
11325
- function positionPopover(pop, anchor, host) {
11718
+ function positionPopover(pop, anchor) {
11326
11719
  const a = anchor.getBoundingClientRect();
11327
- const h = host.getBoundingClientRect();
11328
- pop.style.left = `${a.left - h.left}px`;
11329
- pop.style.top = `${a.bottom - h.top + 4}px`;
11720
+ const vw = window.innerWidth || document.documentElement.clientWidth || 0;
11721
+ const vh = window.innerHeight || document.documentElement.clientHeight || 0;
11722
+ const margin = 8;
11723
+ const gap = 4;
11330
11724
  pop.style.width = `${a.width}px`;
11331
- const maxH = Math.max(80, h.bottom - a.bottom - 8);
11332
- pop.style.maxHeight = `${maxH}px`;
11725
+ const left = Math.min(Math.max(margin, a.left), Math.max(margin, vw - a.width - margin));
11726
+ pop.style.left = `${left}px`;
11727
+ const spaceBelow = vh - a.bottom - margin;
11728
+ const spaceAbove = a.top - margin;
11729
+ if (spaceBelow >= 160 || spaceBelow >= spaceAbove) {
11730
+ pop.style.top = `${a.bottom + gap}px`;
11731
+ pop.style.bottom = "";
11732
+ pop.style.maxHeight = `${Math.max(80, spaceBelow - gap)}px`;
11733
+ } else {
11734
+ pop.style.top = "";
11735
+ pop.style.bottom = `${vh - a.top + gap}px`;
11736
+ pop.style.maxHeight = `${Math.max(80, spaceAbove - gap)}px`;
11737
+ }
11333
11738
  }
11334
11739
  function clamp7(v, lo, hi) {
11335
11740
  return v < lo ? lo : v > hi ? hi : v;
@@ -11390,12 +11795,14 @@ function emptyNotice(host, message, color) {
11390
11795
  }
11391
11796
 
11392
11797
  // src/charts/slicers/dropdown.ts
11798
+ var openMenus = /* @__PURE__ */ new WeakMap();
11393
11799
  function drawDropdown(surface, spec, tokens, size, context) {
11394
11800
  const s = spec;
11395
11801
  if (surface.headless) {
11396
11802
  drawDropdownCanvas(surface, s, tokens, size, context);
11397
11803
  return;
11398
11804
  }
11805
+ openMenus.get(surface)?.();
11399
11806
  const shell = mountSlicerShell(surface, tokens, size, {
11400
11807
  title: s.title,
11401
11808
  label: slicerLabel(s)
@@ -11429,16 +11836,34 @@ function drawDropdown(surface, spec, tokens, size, context) {
11429
11836
  emit();
11430
11837
  };
11431
11838
  let pop = null;
11839
+ const reposition = () => {
11840
+ if (!pop) return;
11841
+ if (!trigger.isConnected) {
11842
+ closeMenu();
11843
+ return;
11844
+ }
11845
+ positionPopover(pop, trigger);
11846
+ };
11432
11847
  const closeMenu = () => {
11433
11848
  if (pop) {
11434
11849
  pop.remove();
11435
11850
  pop = null;
11436
11851
  document.removeEventListener("pointerdown", onOutside, true);
11852
+ document.removeEventListener("keydown", onKey, true);
11853
+ window.removeEventListener("scroll", reposition, true);
11854
+ window.removeEventListener("resize", reposition);
11855
+ openMenus.delete(surface);
11437
11856
  }
11438
11857
  };
11439
11858
  const onOutside = (e) => {
11440
11859
  if (pop && !pop.contains(e.target) && !trigger.contains(e.target)) closeMenu();
11441
11860
  };
11861
+ const onKey = (e) => {
11862
+ if (e.key === "Escape" && pop) {
11863
+ closeMenu();
11864
+ trigger.focus();
11865
+ }
11866
+ };
11442
11867
  const openMenu = () => {
11443
11868
  if (pop) {
11444
11869
  closeMenu();
@@ -11469,9 +11894,13 @@ function drawDropdown(surface, spec, tokens, size, context) {
11469
11894
  });
11470
11895
  pop.appendChild(row);
11471
11896
  }
11472
- shell.host.appendChild(pop);
11473
- positionPopover(pop, trigger, shell.host);
11897
+ document.body.appendChild(pop);
11898
+ positionPopover(pop, trigger);
11899
+ openMenus.set(surface, closeMenu);
11474
11900
  document.addEventListener("pointerdown", onOutside, true);
11901
+ document.addEventListener("keydown", onKey, true);
11902
+ window.addEventListener("scroll", reposition, true);
11903
+ window.addEventListener("resize", reposition);
11475
11904
  };
11476
11905
  trigger.addEventListener("click", openMenu);
11477
11906
  refreshChrome();
@@ -12131,10 +12560,12 @@ function tooltipEnabled(spec) {
12131
12560
  return true;
12132
12561
  }
12133
12562
  function buildCartesianInteraction(model) {
12134
- if (!tooltipEnabled(model.spec)) return null;
12135
- if (model.series.length === 0) return null;
12136
- if (model.spec.type === "scatter") return scatterInteraction(model);
12137
- return indexInteraction(model);
12563
+ if (!tooltipEnabled(model.spec) || model.series.length === 0) {
12564
+ return model.legendHits?.length ? { region: model.plot, hitTest: () => null, legendHits: model.legendHits } : null;
12565
+ }
12566
+ const interaction = model.spec.type === "scatter" ? scatterInteraction(model) : indexInteraction(model);
12567
+ if (interaction) interaction.legendHits = model.legendHits;
12568
+ return interaction;
12138
12569
  }
12139
12570
  function inside(r, px, py, pad3 = 0) {
12140
12571
  return px >= r.x - pad3 && px <= r.x + r.width + pad3 && py >= r.y - pad3 && py <= r.y + r.height + pad3;
@@ -12148,6 +12579,7 @@ function indexInteraction(model) {
12148
12579
  const xFormat = model.spec.encoding.x?.format;
12149
12580
  const yFormat = model.spec.encoding.y?.format;
12150
12581
  const band = x.kind === "band";
12582
+ const horizontal = model.orientation === "horizontal";
12151
12583
  const showDots = !model.stacked && !band;
12152
12584
  const stopByKey = /* @__PURE__ */ new Map();
12153
12585
  const lookups = [];
@@ -12182,7 +12614,7 @@ function indexInteraction(model) {
12182
12614
  };
12183
12615
  const hitTest = (px, py) => {
12184
12616
  if (!inside(plot, px, py, 6)) return null;
12185
- const best = nearestStop(px);
12617
+ const best = nearestStop(horizontal ? py : px);
12186
12618
  const rows = [];
12187
12619
  const dots = [];
12188
12620
  let nearestDy = Infinity;
@@ -12210,15 +12642,19 @@ function indexInteraction(model) {
12210
12642
  const anchorY = showDots && nearestIdx >= 0 ? dots[nearestIdx].py : py;
12211
12643
  return {
12212
12644
  key: best.key,
12213
- anchorX: best.px,
12214
- anchorY,
12645
+ anchorX: horizontal ? px : best.px,
12646
+ anchorY: horizontal ? best.px : anchorY,
12215
12647
  content: { title: formatValue(best.value, xFormat), rows },
12216
12648
  draw: (ctx) => {
12217
12649
  ctx.save();
12218
12650
  if (band) {
12219
12651
  const half = x.bandwidth / 2;
12220
12652
  ctx.fillStyle = withAlpha(model.tokens.color.text, 0.06);
12221
- ctx.fillRect(best.px - half, plot.y, x.bandwidth, plot.height);
12653
+ if (horizontal) {
12654
+ ctx.fillRect(plot.x, best.px - half, plot.width, x.bandwidth);
12655
+ } else {
12656
+ ctx.fillRect(best.px - half, plot.y, x.bandwidth, plot.height);
12657
+ }
12222
12658
  } else {
12223
12659
  ctx.strokeStyle = withAlpha(model.tokens.color.text, 0.28);
12224
12660
  ctx.lineWidth = 1;
@@ -12247,7 +12683,7 @@ function indexInteraction(model) {
12247
12683
  };
12248
12684
  const pick = (px, py) => {
12249
12685
  if (!inside(plot, px, py, 6)) return null;
12250
- const best = nearestStop(px);
12686
+ const best = nearestStop(horizontal ? py : px);
12251
12687
  return { kind: "point", fields: [xField], tuples: [[best.value]] };
12252
12688
  };
12253
12689
  return { region: plot, hitTest, pick };
@@ -12285,17 +12721,35 @@ function scatterInteraction(model) {
12285
12721
  }
12286
12722
  }
12287
12723
  if (points.length === 0) return null;
12724
+ const cell = HIT_RADIUS;
12725
+ const grid = /* @__PURE__ */ new Map();
12726
+ for (let i = 0; i < points.length; i++) {
12727
+ const col = Math.floor(points[i].px / cell);
12728
+ const row = Math.floor(points[i].py / cell);
12729
+ const key = `${col},${row}`;
12730
+ const bucket = grid.get(key);
12731
+ if (bucket) bucket.push(i);
12732
+ else grid.set(key, [i]);
12733
+ }
12288
12734
  const nearest = (px, py) => {
12289
12735
  if (!inside(plot, px, py, HIT_RADIUS)) return -1;
12290
12736
  let best = -1;
12291
12737
  let bestSq = HIT_RADIUS * HIT_RADIUS;
12292
- for (let i = 0; i < points.length; i++) {
12293
- const dx = points[i].px - px;
12294
- const dy = points[i].py - py;
12295
- const sq = dx * dx + dy * dy;
12296
- if (sq <= bestSq) {
12297
- bestSq = sq;
12298
- best = i;
12738
+ const col = Math.floor(px / cell);
12739
+ const row = Math.floor(py / cell);
12740
+ for (let dc = -1; dc <= 1; dc++) {
12741
+ for (let dr = -1; dr <= 1; dr++) {
12742
+ const bucket = grid.get(`${col + dc},${row + dr}`);
12743
+ if (!bucket) continue;
12744
+ for (const i of bucket) {
12745
+ const dx = points[i].px - px;
12746
+ const dy = points[i].py - py;
12747
+ const sq = dx * dx + dy * dy;
12748
+ if (sq < bestSq || sq === bestSq && i > best) {
12749
+ bestSq = sq;
12750
+ best = i;
12751
+ }
12752
+ }
12299
12753
  }
12300
12754
  }
12301
12755
  return best;
@@ -12596,6 +13050,8 @@ function buildComboModel(spec, tokens, opts) {
12596
13050
  series: [{ key: "", label: name, color, value: name, rows: data }],
12597
13051
  seriesField: void 0,
12598
13052
  stacked: false,
13053
+ orientation: "vertical",
13054
+ project: (c, v) => ({ x: c, y: v }),
12599
13055
  xTicks,
12600
13056
  yTicks: sideTicks,
12601
13057
  colorOf: () => color,
@@ -12623,6 +13079,8 @@ function buildComboModel(spec, tokens, opts) {
12623
13079
  series: [],
12624
13080
  seriesField: void 0,
12625
13081
  stacked: false,
13082
+ orientation: "vertical",
13083
+ project: (c, v) => ({ x: c, y: v }),
12626
13084
  xTicks,
12627
13085
  yTicks: leftTicks,
12628
13086
  colorOf: () => "",
@@ -12860,6 +13318,8 @@ function buildHistogramModel(spec, tokens, opts) {
12860
13318
  series: [],
12861
13319
  seriesField: void 0,
12862
13320
  stacked: false,
13321
+ orientation: "vertical",
13322
+ project: (c, v) => ({ x: c, y: v }),
12863
13323
  xTicks,
12864
13324
  yTicks,
12865
13325
  colorOf: () => color,
@@ -13269,8 +13729,8 @@ function resolveValue2(ref, data) {
13269
13729
  function valueField2(ref) {
13270
13730
  return typeof ref === "number" ? null : ref.field;
13271
13731
  }
13272
- function titleText(title) {
13273
- return resolveTitle2(title).text;
13732
+ function titleText(title2) {
13733
+ return resolveTitle2(title2).text;
13274
13734
  }
13275
13735
  function withAlpha3(color, alpha) {
13276
13736
  const rgba2 = parseColor(color);
@@ -13454,9 +13914,9 @@ function drawGauge(surface, spec, tokens, size, context) {
13454
13914
  function clamp9(value, min, max) {
13455
13915
  return Math.max(min, Math.min(max, value));
13456
13916
  }
13457
- function titleText2(title) {
13458
- if (typeof title === "string") return title;
13459
- if (title && typeof title === "object") return title.text;
13917
+ function titleText2(title2) {
13918
+ if (typeof title2 === "string") return title2;
13919
+ if (title2 && typeof title2 === "object") return title2.text;
13460
13920
  return void 0;
13461
13921
  }
13462
13922
  function valueField3(ref) {
@@ -14063,6 +14523,8 @@ function buildWaterfallModel(spec, tokens, opts) {
14063
14523
  series: [],
14064
14524
  seriesField: void 0,
14065
14525
  stacked: false,
14526
+ orientation: "vertical",
14527
+ project: (c, v) => ({ x: c, y: v }),
14066
14528
  xTicks,
14067
14529
  yTicks,
14068
14530
  colorOf: () => incColor,
@@ -14324,6 +14786,8 @@ function buildSlopeModel(spec, tokens, opts) {
14324
14786
  series: [],
14325
14787
  seriesField: void 0,
14326
14788
  stacked: false,
14789
+ orientation: "vertical",
14790
+ project: (c, v) => ({ x: c, y: v }),
14327
14791
  xTicks,
14328
14792
  yTicks,
14329
14793
  colorOf: () => tokens.color.text,
@@ -14798,9 +15262,14 @@ function toPixel(model, axis, value) {
14798
15262
  return px != null && Number.isFinite(px) ? px : void 0;
14799
15263
  }
14800
15264
  function pointPixels(model, ann) {
14801
- const px = toPixel(model, "x", ann.x);
14802
- const py = toPixel(model, "y", ann.y);
14803
- return px === void 0 || py === void 0 ? void 0 : { px, py };
15265
+ const cat = toPixel(model, "x", ann.x);
15266
+ const val = toPixel(model, "y", ann.y);
15267
+ if (cat === void 0 || val === void 0) return void 0;
15268
+ const { x, y } = model.project(cat, val);
15269
+ return { px: x, py: y };
15270
+ }
15271
+ function drawsHorizontalRule(model, axis) {
15272
+ return model.orientation === "horizontal" ? axis === "x" : axis === "y";
14804
15273
  }
14805
15274
  function anchorPixel(model, ann, axis, kind) {
14806
15275
  if (kind === "line") return toPixel(model, axis, ann.value);
@@ -14849,6 +15318,7 @@ function drawAnnotations(surface, model) {
14849
15318
  }
14850
15319
  const axis = ann.axis === "x" ? "x" : "y";
14851
15320
  const color = ann.color ?? tokens.color.textMuted;
15321
+ const ruleHorizontal = drawsHorizontalRule(model, axis);
14852
15322
  if (kindOf(ann) === "line") {
14853
15323
  const p = toPixel(model, axis, ann.value);
14854
15324
  if (p === void 0) continue;
@@ -14857,7 +15327,7 @@ function drawAnnotations(surface, model) {
14857
15327
  ctx.lineWidth = ann.strokeWidth ?? 1.5;
14858
15328
  ctx.setLineDash(ann.strokeDash ?? DEFAULT_DASH);
14859
15329
  ctx.beginPath();
14860
- if (axis === "y") {
15330
+ if (ruleHorizontal) {
14861
15331
  const y = crisp(p);
14862
15332
  ctx.moveTo(plot.x, y);
14863
15333
  ctx.lineTo(x1, y);
@@ -14878,7 +15348,7 @@ function drawAnnotations(surface, model) {
14878
15348
  ctx.save();
14879
15349
  ctx.globalAlpha = ann.fillOpacity ?? DEFAULT_FILL_OPACITY;
14880
15350
  ctx.fillStyle = color;
14881
- if (axis === "y") ctx.fillRect(plot.x, lo, plot.width, hi - lo);
15351
+ if (ruleHorizontal) ctx.fillRect(plot.x, lo, plot.width, hi - lo);
14882
15352
  else ctx.fillRect(lo, plot.y, hi - lo, plot.height);
14883
15353
  ctx.restore();
14884
15354
  ctx.save();
@@ -14887,7 +15357,7 @@ function drawAnnotations(surface, model) {
14887
15357
  ctx.lineWidth = 1;
14888
15358
  ctx.setLineDash(ann.strokeDash ?? [4, 4]);
14889
15359
  ctx.beginPath();
14890
- if (axis === "y") {
15360
+ if (ruleHorizontal) {
14891
15361
  ctx.moveTo(plot.x, crisp(lo));
14892
15362
  ctx.lineTo(x1, crisp(lo));
14893
15363
  ctx.moveTo(plot.x, crisp(hi));
@@ -14903,10 +15373,10 @@ function drawAnnotations(surface, model) {
14903
15373
  }
14904
15374
  ctx.restore();
14905
15375
  }
14906
- function labelBox(model, axis, pos, p) {
15376
+ function labelBox(model, horizontalRule, pos, p) {
14907
15377
  const { plot } = model;
14908
15378
  const inset = 6;
14909
- if (axis === "y") {
15379
+ if (horizontalRule) {
14910
15380
  const top = p - 3;
14911
15381
  const transform3 = "translateY(-100%)";
14912
15382
  if (pos === "start") return { left: plot.x + inset, top, width: plot.width - inset, align: "left", transform: transform3 };
@@ -14942,7 +15412,7 @@ function drawAnnotationLabels(surface, model) {
14942
15412
  const axis = ann.axis === "x" ? "x" : "y";
14943
15413
  const p = anchorPixel(model, ann, axis, kindOf(ann));
14944
15414
  if (p === void 0) continue;
14945
- box = labelBox(model, axis, ann.labelPosition ?? "end", p);
15415
+ box = labelBox(model, drawsHorizontalRule(model, axis), ann.labelPosition ?? "end", p);
14946
15416
  }
14947
15417
  const color = ann.color ?? tokens.color.text;
14948
15418
  const pill = { background: withAlpha4(tokens.color.background, 0.72), radius: 3, padX: 3, padY: 1 };
@@ -15285,6 +15755,13 @@ var Tooltip = class {
15285
15755
  };
15286
15756
 
15287
15757
  // src/interaction/controller.ts
15758
+ function resolveLegendHit(model, px, py) {
15759
+ for (const hit of model.legendHits ?? []) {
15760
+ const r = hit.rect;
15761
+ if (px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height) return hit;
15762
+ }
15763
+ return null;
15764
+ }
15288
15765
  var InteractionController = class {
15289
15766
  constructor(surface, tokens) {
15290
15767
  __publicField(this, "surface", surface);
@@ -15313,9 +15790,17 @@ var InteractionController = class {
15313
15790
  __publicField(this, "onClick", (e) => {
15314
15791
  const cfg = this.selectCfg;
15315
15792
  const model = this.model;
15316
- if (!cfg || !model || !model.pick) return;
15793
+ if (!cfg || !model) return;
15317
15794
  const rect = this.surface.root.getBoundingClientRect();
15318
- cfg.onPick(model.pick(e.clientX - rect.left, e.clientY - rect.top));
15795
+ const px = e.clientX - rect.left;
15796
+ const py = e.clientY - rect.top;
15797
+ const onLegendToggle = cfg.onLegendToggle;
15798
+ const legendHit = onLegendToggle ? resolveLegendHit(model, px, py) : null;
15799
+ if (legendHit && onLegendToggle) {
15800
+ onLegendToggle(legendHit, e);
15801
+ return;
15802
+ }
15803
+ if (model.pick && cfg.onPick) cfg.onPick(model.pick(px, py));
15319
15804
  });
15320
15805
  __publicField(this, "flush", () => {
15321
15806
  this.raf = 0;
@@ -15343,7 +15828,7 @@ var InteractionController = class {
15343
15828
  this.tokens = tokens;
15344
15829
  this.tooltip.setTokens(tokens);
15345
15830
  this.activeKey = null;
15346
- this.pickable = Boolean(model?.pick);
15831
+ this.pickable = Boolean(model?.pick || model?.legendHits?.length);
15347
15832
  if (!model) this.tooltip.hide();
15348
15833
  }
15349
15834
  /** Wire click/tap selection for this chart (or `null` to disable it). */
@@ -15611,6 +16096,14 @@ function buildRenderReport(input) {
15611
16096
  const colorCount = model ? new Set(seriesColors).size || 1 : seriesCount;
15612
16097
  const markCount = model ? model.series.reduce((n, s) => n + s.rows.length, 0) : data.length;
15613
16098
  const plot = model?.plot;
16099
+ if (type === "scatter" && model?.markStats && model.markStats.drawn < model.markStats.original) {
16100
+ add({
16101
+ code: "scatter-quantized",
16102
+ severity: "info",
16103
+ message: `Dense scatter rendering was quantized from ${model.markStats.original} to ${model.markStats.drawn} drawn points to keep interaction smooth.`,
16104
+ details: { drawn: model.markStats.drawn, original: model.markStats.original }
16105
+ });
16106
+ }
15614
16107
  const hasLiteralValue = VALUE_CHART_TYPES.has(type) && typeof spec.value === "number";
15615
16108
  if (data.length === 0 && !hasLiteralValue) {
15616
16109
  add({
@@ -15634,9 +16127,9 @@ function buildRenderReport(input) {
15634
16127
  }
15635
16128
  if (model) {
15636
16129
  checkDegenerateAxis(model, add);
15637
- checkClippedMarks(model, add);
15638
- checkAxisLabelOverlap(model, tokens, add);
15639
- checkLegendOverflow(model, add);
16130
+ checkClippedMarks(model, spec, add);
16131
+ checkAxisLabelOverlap(model, spec, tokens, add);
16132
+ checkLegendOverflow(model, spec, add);
15640
16133
  }
15641
16134
  if (bg) {
15642
16135
  const specColor = spec.color;
@@ -15675,7 +16168,8 @@ function buildRenderReport(input) {
15675
16168
  code: "too-many-colors",
15676
16169
  severity: "info",
15677
16170
  message: `${colorCount} series share one color scale \u2014 beyond ~${MAX_DISTINGUISHABLE_SERIES} the colors repeat or blur together. Consider faceting, filtering, or grouping the long tail.`,
15678
- details: { colorCount, max: MAX_DISTINGUISHABLE_SERIES }
16171
+ details: { colorCount, max: MAX_DISTINGUISHABLE_SERIES },
16172
+ ...tooManyColorsRepair(spec)
15679
16173
  });
15680
16174
  }
15681
16175
  diagnostics.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
@@ -15683,6 +16177,11 @@ function buildRenderReport(input) {
15683
16177
  const summary = summarize({ ...spec, data }) || void 0;
15684
16178
  return { type, size, plot, markCount, seriesCount, colorCount, ok, diagnostics, summary };
15685
16179
  }
16180
+ function repairReport(spec, report) {
16181
+ const applied = report.diagnostics.flatMap((d) => d.fix ?? []);
16182
+ if (applied.length === 0) return { spec, applied: [] };
16183
+ return { spec: applyPatch(spec, applied), applied };
16184
+ }
15686
16185
  function seriesCountOf(spec, data) {
15687
16186
  const enc = spec.encoding;
15688
16187
  const field = enc?.series?.field ?? enc?.color?.field;
@@ -15714,7 +16213,7 @@ function checkDegenerateAxis(model, add) {
15714
16213
  });
15715
16214
  }
15716
16215
  }
15717
- function checkClippedMarks(model, add) {
16216
+ function checkClippedMarks(model, spec, add) {
15718
16217
  const field = model.y.field;
15719
16218
  if (!field) return;
15720
16219
  const read = accessor(field);
@@ -15734,20 +16233,25 @@ function checkClippedMarks(model, add) {
15734
16233
  const pHi = model.y.pixel(hi);
15735
16234
  const pLo = model.y.pixel(lo);
15736
16235
  const EPS = 0.75;
15737
- if (pHi < top - EPS || pLo > bottom + EPS) {
16236
+ const clipped = model.orientation === "horizontal" ? pLo < model.plot.x - EPS || pHi > model.plot.x + model.plot.width + EPS : pHi < top - EPS || pLo > bottom + EPS;
16237
+ if (clipped) {
15738
16238
  add({
15739
16239
  code: "marks-clipped",
15740
16240
  severity: "warning",
15741
16241
  axis: "y",
15742
16242
  message: `Some data falls outside the y-axis range [${round(Math.min(lo, hi))}, ${round(Math.max(lo, hi))}] and is clipped at the plot edge \u2014 widen the y domain or drop the manual scale.`,
15743
- details: { dataMin: round(lo), dataMax: round(hi) }
16243
+ details: { dataMin: round(lo), dataMax: round(hi) },
16244
+ ...manualYDomainFix(spec) ?? {
16245
+ hint: "Drop the manual y scale domain or set a y scale domain/zero option that includes every data value."
16246
+ }
15744
16247
  });
15745
16248
  }
15746
16249
  }
15747
- function checkAxisLabelOverlap(model, tokens, add) {
16250
+ function checkAxisLabelOverlap(model, spec, tokens, add) {
15748
16251
  const ticks2 = model.xTicks;
15749
16252
  if (ticks2.length < 2) return;
15750
- if (model.frame.xLabelAngle > 0) return;
16253
+ const horizontal = model.orientation === "horizontal";
16254
+ if (!horizontal && model.frame.xLabelAngle > 0) return;
15751
16255
  const font = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
15752
16256
  const GAP3 = 4;
15753
16257
  let collisions = 0;
@@ -15755,7 +16259,7 @@ function checkAxisLabelOverlap(model, tokens, add) {
15755
16259
  const prev = ticks2[i - 1];
15756
16260
  const cur = ticks2[i];
15757
16261
  const spacing = Math.abs(cur.pos - prev.pos);
15758
- const half = (measureText(prev.label, font).width + measureText(cur.label, font).width) / 2;
16262
+ const half = horizontal ? tokens.font.size.small : (measureText(prev.label, font).width + measureText(cur.label, font).width) / 2;
15759
16263
  if (half + GAP3 > spacing) collisions++;
15760
16264
  }
15761
16265
  if (collisions > 0) {
@@ -15764,11 +16268,14 @@ function checkAxisLabelOverlap(model, tokens, add) {
15764
16268
  severity: "warning",
15765
16269
  axis: "x",
15766
16270
  message: `${collisions} adjacent x-axis label${collisions === 1 ? "" : "s"} overlap \u2014 there isn't room to show all ${ticks2.length} categories. Reduce categories, widen the chart, or aggregate the long tail.`,
15767
- details: { collisions, ticks: ticks2.length }
16271
+ details: { collisions, ticks: ticks2.length },
16272
+ ...xLabelAngleFix(spec) ?? {
16273
+ hint: "Rotate x-axis labels, facet the chart, widen it, or aggregate the long tail."
16274
+ }
15768
16275
  });
15769
16276
  }
15770
16277
  }
15771
- function checkLegendOverflow(model, add) {
16278
+ function checkLegendOverflow(model, spec, add) {
15772
16279
  const shown = model.frame.legendItems?.length;
15773
16280
  if (shown === void 0) return;
15774
16281
  const total = model.series.length;
@@ -15777,10 +16284,52 @@ function checkLegendOverflow(model, add) {
15777
16284
  code: "legend-overflow",
15778
16285
  severity: "warning",
15779
16286
  message: `The legend shows only ${shown} of ${total} series \u2014 the rest were clipped to fit. Move the legend to the top/bottom or give the chart more height.`,
15780
- details: { shown, total }
16287
+ details: { shown, total },
16288
+ ...legendPositionFix(spec) ?? {
16289
+ hint: "Move the legend to top/bottom, increase chart height, filter series, or facet the chart."
16290
+ }
15781
16291
  });
15782
16292
  }
15783
16293
  }
16294
+ function isRecord(v) {
16295
+ return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
16296
+ }
16297
+ function xLabelAngleFix(spec) {
16298
+ const axes = spec.axes;
16299
+ if (!isRecord(axes)) return void 0;
16300
+ const x = axes.x;
16301
+ if (x === void 0) return { fix: [{ op: "add", path: "/axes/x", value: { labelAngle: 45 } }] };
16302
+ if (!isRecord(x)) return void 0;
16303
+ const op = Object.prototype.hasOwnProperty.call(x, "labelAngle") ? "replace" : "add";
16304
+ return { fix: [{ op, path: "/axes/x/labelAngle", value: 45 }] };
16305
+ }
16306
+ function legendPositionFix(spec) {
16307
+ const legend = spec.legend;
16308
+ if (legend === false) return void 0;
16309
+ if (legend === void 0 || legend === true) {
16310
+ return { fix: [{ op: legend === void 0 ? "add" : "replace", path: "/legend", value: { position: "bottom" } }] };
16311
+ }
16312
+ if (!isRecord(legend)) return void 0;
16313
+ const current = legend.position;
16314
+ if (current === "bottom" || current === "top") return void 0;
16315
+ const op = Object.prototype.hasOwnProperty.call(legend, "position") ? "replace" : "add";
16316
+ return { fix: [{ op, path: "/legend/position", value: "bottom" }] };
16317
+ }
16318
+ function tooManyColorsRepair(spec) {
16319
+ const palette = spec.palette;
16320
+ const hint = "Switch to the colorblind palette, facet the chart, filter series, or group the long tail.";
16321
+ if (Array.isArray(palette) || palette === "colorblind") return { hint };
16322
+ return { fix: [{ op: palette === void 0 ? "add" : "replace", path: "/palette", value: "colorblind" }], hint };
16323
+ }
16324
+ function manualYDomainFix(spec) {
16325
+ const enc = spec.encoding;
16326
+ if (!isRecord(enc)) return void 0;
16327
+ const y = enc.y;
16328
+ if (!isRecord(y)) return void 0;
16329
+ const scale = y.scale;
16330
+ if (!isRecord(scale) || !Object.prototype.hasOwnProperty.call(scale, "domain")) return void 0;
16331
+ return { fix: [{ op: "remove", path: "/encoding/y/scale/domain" }] };
16332
+ }
15784
16333
 
15785
16334
  // src/runtime/facet.ts
15786
16335
  var FACETABLE_TYPES2 = /* @__PURE__ */ new Set(["line", "area", "bar", "scatter"]);
@@ -15816,9 +16365,9 @@ function autoColumns(n) {
15816
16365
  function legendSymbol(type) {
15817
16366
  return type === "scatter" ? "circle" : type === "line" ? "line" : "square";
15818
16367
  }
15819
- function titleInput3(title) {
15820
- if (!title) return void 0;
15821
- return typeof title === "string" ? { text: title } : title;
16368
+ function titleInput3(title2) {
16369
+ if (!title2) return void 0;
16370
+ return typeof title2 === "string" ? { text: title2 } : title2;
15822
16371
  }
15823
16372
  function panelSpec(spec, data, value) {
15824
16373
  const base = spec;
@@ -15989,6 +16538,60 @@ var CANVAS_MARK_TYPES = /* @__PURE__ */ new Set([
15989
16538
  "choropleth"
15990
16539
  ]);
15991
16540
  var EMPTY_DATA = [];
16541
+ function legendConfigFor(model) {
16542
+ const legend = model.spec.legend;
16543
+ if (!(typeof legend === "object" && legend.interactive === true)) return null;
16544
+ const field = model.seriesField;
16545
+ if (!field || !model.legendHits?.length) return null;
16546
+ return { param: legend.param ?? field, field };
16547
+ }
16548
+ function visibleLegendKeys(value, field) {
16549
+ if (!value || value.kind !== "set" || value.field !== field) return null;
16550
+ return new Set(value.values.map((v) => toKey(v)));
16551
+ }
16552
+ function applyLegendVisibility(model, store) {
16553
+ const cfg = legendConfigFor(model);
16554
+ if (!cfg) return null;
16555
+ const visible = visibleLegendKeys(store.get(cfg.param), cfg.field);
16556
+ if (!visible) return cfg;
16557
+ model.series = model.series.filter((s) => visible.has(toKey(s.value)));
16558
+ return cfg;
16559
+ }
16560
+ function publishLegendToggle(store, cfg, allHits, hit, isolate) {
16561
+ const all = allHits.map((h) => h.value);
16562
+ const allKeys = all.map((v) => toKey(v));
16563
+ const hitKey = toKey(hit.value);
16564
+ const current = visibleLegendKeys(store.get(cfg.param), cfg.field);
16565
+ const visible = current ? new Set(current) : new Set(allKeys);
16566
+ if (isolate) {
16567
+ if (visible.size === 1 && visible.has(hitKey)) {
16568
+ store.set(cfg.param, null);
16569
+ return;
16570
+ }
16571
+ visible.clear();
16572
+ visible.add(hitKey);
16573
+ } else if (visible.has(hitKey)) {
16574
+ visible.delete(hitKey);
16575
+ } else {
16576
+ visible.add(hitKey);
16577
+ }
16578
+ if (visible.size === 0 || visible.size === allKeys.length) {
16579
+ store.set(cfg.param, null);
16580
+ return;
16581
+ }
16582
+ store.set(cfg.param, {
16583
+ kind: "set",
16584
+ field: cfg.field,
16585
+ values: all.filter((v) => visible.has(toKey(v)))
16586
+ });
16587
+ }
16588
+ function specLegendParam(spec) {
16589
+ const legend = spec.legend;
16590
+ if (!(typeof legend === "object" && legend.interactive === true)) return null;
16591
+ if (legend.param) return legend.param;
16592
+ const enc = spec.encoding;
16593
+ return enc?.series?.field ?? enc?.color?.field ?? null;
16594
+ }
15992
16595
  function resolveContainer(target) {
15993
16596
  if (typeof target === "string") {
15994
16597
  const el = document.querySelector(target);
@@ -16084,6 +16687,7 @@ function render(target, spec, options) {
16084
16687
  let interactionModel = null;
16085
16688
  let reportModel;
16086
16689
  let facetRpt;
16690
+ let legendSelect = null;
16087
16691
  const type = currentSpec.type;
16088
16692
  const facetLayout = isFaceted(effectiveSpec) ? buildFacetModels(effectiveSpec, tokens, size) : null;
16089
16693
  if (facetLayout) {
@@ -16092,6 +16696,8 @@ function render(target, spec, options) {
16092
16696
  } else if (CARTESIAN_TYPES.has(type)) {
16093
16697
  const renderer = cartesianRenderers[type];
16094
16698
  const model = buildCartesianModel(effectiveSpec, tokens, size);
16699
+ const legendCfg = applyLegendVisibility(model, store);
16700
+ if (legendCfg && model.legendHits?.length) legendSelect = { cfg: legendCfg, hits: model.legendHits };
16095
16701
  model.emphasis = emphasis;
16096
16702
  reportModel = model;
16097
16703
  if (!anim.enabled) {
@@ -16140,9 +16746,14 @@ function render(target, spec, options) {
16140
16746
  }
16141
16747
  if (!interaction) interaction = new InteractionController(surface, tokens);
16142
16748
  interaction.setModel(interactionModel, tokens);
16143
- if (ownParam && interactionModel?.pick) {
16144
- const cfg = { store, param: ownParam.name, def: ownParam.select };
16145
- interaction.setSelect({ onPick: (value) => applyPick(cfg, value) });
16749
+ if (ownParam && interactionModel?.pick || legendSelect && interactionModel?.legendHits?.length) {
16750
+ const cfg = ownParam ? { store, param: ownParam.name, def: ownParam.select } : null;
16751
+ interaction.setSelect({
16752
+ onPick: cfg && interactionModel?.pick ? (value) => applyPick(cfg, value) : void 0,
16753
+ onLegendToggle: legendSelect ? (hit, event) => {
16754
+ publishLegendToggle(store, legendSelect.cfg, legendSelect.hits, hit, event.shiftKey || event.altKey);
16755
+ } : void 0
16756
+ });
16146
16757
  } else {
16147
16758
  interaction.setSelect(null);
16148
16759
  }
@@ -16199,7 +16810,7 @@ function render(target, spec, options) {
16199
16810
  const unsubscribe = store.subscribe((name, value) => {
16200
16811
  for (const listener of [...hostListeners]) listener(name, value);
16201
16812
  if (destroyed) return;
16202
- if (dependentParams(currentSpec.highlight, currentSpec.filter).has(name)) {
16813
+ if (dependentParams(currentSpec.highlight, currentSpec.filter).has(name) || specLegendParam(currentSpec) === name) {
16203
16814
  draw(false);
16204
16815
  }
16205
16816
  });
@@ -16380,6 +16991,14 @@ function isSlicerType(type) {
16380
16991
  function fieldOf(channel) {
16381
16992
  return channel && typeof channel.field === "string" ? channel.field : void 0;
16382
16993
  }
16994
+ function legendSource(spec) {
16995
+ const legend = spec.legend;
16996
+ if (!(typeof legend === "object" && legend.interactive === true)) return null;
16997
+ const enc = spec.encoding;
16998
+ const field = fieldOf(enc?.series) ?? fieldOf(enc?.color);
16999
+ if (!field) return null;
17000
+ return { param: legend.param ?? field, field };
17001
+ }
16383
17002
  function specFields(spec) {
16384
17003
  const out = /* @__PURE__ */ new Set();
16385
17004
  if (isSlicerType(spec.type)) {
@@ -16468,9 +17087,14 @@ function wireViews(views, interactions) {
16468
17087
  });
16469
17088
  } else if (PICKABLE.has(type)) {
16470
17089
  const fields = keyFields(v.spec);
16471
- if (fields.length === 0) return;
16472
- const param = autoParamName(v);
16473
- sources.push({ view: i, param, fields, as: "highlight", slicer: false });
17090
+ if (fields.length > 0) {
17091
+ const param = autoParamName(v);
17092
+ sources.push({ view: i, param, fields, as: "highlight", slicer: false });
17093
+ }
17094
+ const legend = legendSource(v.spec);
17095
+ if (legend) {
17096
+ sources.push({ view: i, param: legend.param, fields: [legend.field], as: "filter", slicer: true });
17097
+ }
16474
17098
  }
16475
17099
  });
16476
17100
  const explicit = Array.isArray(interactions);
@@ -16536,6 +17160,14 @@ var DEFAULT_BREAKPOINTS = [
16536
17160
  { maxWidth: 960, cols: 6 }
16537
17161
  ];
16538
17162
  var STRIP_SLICERS = /* @__PURE__ */ new Set(["dropdown", "search", "dateRange", "range"]);
17163
+ function stripCellHeight(view, rowHeight) {
17164
+ const titled = !!(view.title || view.subtitle);
17165
+ const titlePad = titled ? 30 : 0;
17166
+ const base = Math.max(96, rowHeight + 8);
17167
+ if (view.spec.type === "dateRange") return Math.max(base, 150 + titlePad);
17168
+ if (view.spec.type === "range") return Math.max(base, 132 + titlePad);
17169
+ return Math.max(base, 96 + titlePad);
17170
+ }
16539
17171
  var clamp10 = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
16540
17172
  function defaultSpan(spec, cols) {
16541
17173
  const t = spec.type;
@@ -16799,14 +17431,14 @@ function renderDashboard(target, spec, options) {
16799
17431
  gap: "2px"
16800
17432
  });
16801
17433
  if (view.title) {
16802
- const title = document.createElement("div");
16803
- title.textContent = view.title;
16804
- Object.assign(title.style, {
17434
+ const title2 = document.createElement("div");
17435
+ title2.textContent = view.title;
17436
+ Object.assign(title2.style, {
16805
17437
  font: `${tokens.font.weight.bold} ${tokens.font.size.base}px ${tokens.font.family}`,
16806
17438
  color: tokens.color.text,
16807
17439
  letterSpacing: "-0.01em"
16808
17440
  });
16809
- header.appendChild(title);
17441
+ header.appendChild(title2);
16810
17442
  }
16811
17443
  if (view.subtitle) {
16812
17444
  const subtitle = document.createElement("div");
@@ -16838,6 +17470,7 @@ function renderDashboard(target, spec, options) {
16838
17470
  Object.assign(strip.style, {
16839
17471
  display: "flex",
16840
17472
  flexWrap: "wrap",
17473
+ alignItems: "flex-start",
16841
17474
  gap: `${layout2.gap}px`
16842
17475
  });
16843
17476
  for (const view of stripViews) {
@@ -16845,7 +17478,7 @@ function renderDashboard(target, spec, options) {
16845
17478
  Object.assign(cell.style, {
16846
17479
  flex: "1 1 220px",
16847
17480
  minWidth: "200px",
16848
- height: `${Math.max(96, layout2.rowHeight + 8)}px`
17481
+ height: `${stripCellHeight(view, layout2.rowHeight)}px`
16849
17482
  });
16850
17483
  strip.appendChild(cell);
16851
17484
  deferredMounts.push({ el: host, view, mounted: false });
@@ -16885,10 +17518,10 @@ function renderDashboard(target, spec, options) {
16885
17518
  const titles = document.createElement("span");
16886
17519
  Object.assign(titles.style, { display: "flex", flexDirection: "column", gap: "2px" });
16887
17520
  if (plan.section?.title) {
16888
- const title = document.createElement("span");
16889
- title.textContent = plan.section.title;
16890
- Object.assign(title.style, { fontWeight: String(tokens.font.weight.bold), fontSize: `${tokens.font.size.large}px` });
16891
- titles.appendChild(title);
17521
+ const title2 = document.createElement("span");
17522
+ title2.textContent = plan.section.title;
17523
+ Object.assign(title2.style, { fontWeight: String(tokens.font.weight.bold), fontSize: `${tokens.font.size.large}px` });
17524
+ titles.appendChild(title2);
16892
17525
  }
16893
17526
  if (plan.section?.subtitle) {
16894
17527
  const subtitle = document.createElement("span");
@@ -17231,6 +17864,6 @@ function renderToContext(target, spec) {
17231
17864
  // src/index.ts
17232
17865
  var VERSION = "0.1.0";
17233
17866
 
17234
- export { CARTESIAN_TYPES, CHART_TYPES, CanvasLayer, DEFAULT_ENTRANCE_DURATION, DEFAULT_ROUGH_STYLE, DEFAULT_UPDATE_DURATION, DIM_ALPHA, ExpressionError, FACETABLE_TYPES2 as FACETABLE_TYPES, FUNCTION_NAMES, InteractionController, RoughPen, SKETCH_FONT_FAMILY, SKETCH_FONT_NAME, SLICER_TYPES, SR_ONLY_STYLE, Surface, TICK_SIZE, TIME_UNITS, TRANSFORM_KINDS, Tooltip, VERSION, aggregateValues, analyzeChart, animate, applyA11y, applyAggregate, applyBin, applyCalculate, applyFilter, applyFold, applyPatch, applyPick, applyTimeUnit, applyTransform, applyTransforms, arc, area, assertValidSpec, autoInsightAnnotations, backOut, bandScale, bounceOut, buildCartesianInteraction, buildCartesianModel, buildComboModel, buildDataTableFallback, buildFacetModels, buildHistogramModel, buildRenderReport, cartesianInteractionBuilders, cartesianRenderers, categorical, chartTitleText, chartTypeLabel, checkExpression, clamp012 as clamp01, clamp255, compileExpression, compilePredicate, computeBackingSize, computeBins, computeFrame, computeSeriesInsights, contrastRatio, createDiv, createRoughPen, createSelectionStore, cubicIn, cubicInOut, cubicOut, curveCatmullRom, curveLinear, curveMonotoneX, curveStep, curveStepAfter, curveStepBefore, customRenderers, darkTheme, decimate, defaultSpan, dependentParams, diverging, divergingColorScale, divergingSchemes, drawAnnotationLabels, drawAnnotations, drawAxesUnderlay, drawFacet, drawLegend, drawOverlay, drawTitle, drawTrendlineLabels, drawTrendlines, easings, elasticOut, ellipsize, ensureSketchFont, expoInOut, expoOut, facetReport, fillParentStyle, filterRows, fontString, formatDate, formatNumber2 as formatNumber, formatValue, getDevicePixelRatio, groupBy, hashString, inferFieldType, inferFieldTypes, inferValueType, inheritInto, interpolateArray, interpolateNumber, interpolateNumberArray, interpolateObject, interpolateOklab, interpolateRgb, isBrowser, isEmptyValue, isFaceted, isParamClause, isSlicerType, keyFields, legendSwatchWidth, lightTheme, line, linear, linearRegression, linearScale, linearToSrgb, lintSpec, literalToValue, logScale, lttbRun, makeMatcher, matchesValue, measureText, monotoneXTangents, mulberry32, niceDomain, oklabToOklch, oklabToRgb, oklchToOklab, ordinalColorScale, overlayTextToCanvasCmd, paintCanvasText, paintLegendSwatch, parseColor, parseNumberFormat, pivot, placeViews, pointScale, polygonHachureLines, prefersReducedMotion, prettyDate, quadIn, quadInOut, quadOut, rampFromStops, readableTextColor, relativeLuminance, render, renderDashboard, renderToContext, repairSpec, resolveCurve, resolveDashboardLayout, resolveDashboardSections, resolveEmphasis, resolveEntrance, resolveFilterValues, resolveInsightOptions, resolveSketch, resolveTheme, resolveUpdate, rgbToOklab, rgbaToCss, rotatePoint, roundedRect, sampleArc, sequential, sequentialColorScale, sequentialSchemes, setMeasureContext, setStyle, sinInOut, slicerParamName, smartDate, specFields, srgbToLinear, summarize, summarizeChart, themes, tickIncrement, tickStep, ticks, timeScale, timeTickFormat, timeTicks, toHex, toPointer, tooltipEnabled, truncateTo, validateDashboard, validateSpec, wireViews, withAlpha, withSketchFont };
17867
+ export { CARTESIAN_TYPES, CHART_TYPES, CanvasLayer, DEFAULT_ENTRANCE_DURATION, DEFAULT_ROUGH_STYLE, DEFAULT_UPDATE_DURATION, DIM_ALPHA, ExpressionError, FACETABLE_TYPES2 as FACETABLE_TYPES, FUNCTION_NAMES, InteractionController, RoughPen, SKETCH_FONT_FAMILY, SKETCH_FONT_NAME, SLICER_TYPES, SR_ONLY_STYLE, Surface, TICK_SIZE, TIME_UNITS, TRANSFORM_KINDS, Tooltip, VERSION, aggregateValues, analyzeChart, animate, applyA11y, applyAggregate, applyBin, applyCalculate, applyFilter, applyFold, applyPatch, applyPick, applyTimeUnit, applyTransform, applyTransforms, arc, area, assertValidSpec, autoInsightAnnotations, backOut, bandScale, bounceOut, buildCartesianInteraction, buildCartesianModel, buildComboModel, buildDataTableFallback, buildFacetModels, buildHistogramModel, buildRenderReport, cartesianInteractionBuilders, cartesianRenderers, categorical, categoricalSchemes, chartTitleText, chartTypeLabel, checkExpression, clamp012 as clamp01, clamp255, compileExpression, compilePredicate, computeBackingSize, computeBins, computeFrame, computeSeriesInsights, contrastRatio, createDiv, createRoughPen, createSelectionStore, cubicIn, cubicInOut, cubicOut, curveCatmullRom, curveLinear, curveMonotoneX, curveStep, curveStepAfter, curveStepBefore, customRenderers, darkTheme, decimate, defaultSpan, dependentParams, diverging, divergingColorScale, divergingSchemes, drawAnnotationLabels, drawAnnotations, drawAxesUnderlay, drawFacet, drawLegend, drawOverlay, drawTitle, drawTrendlineLabels, drawTrendlines, easings, elasticOut, ellipsize, ensureSketchFont, expoInOut, expoOut, facetReport, fillParentStyle, filterRows, fontString, formatDate, formatNumber2 as formatNumber, formatValue, getDevicePixelRatio, groupBy, hashString, inferFieldType, inferFieldTypes, inferValueType, inheritInto, interpolateArray, interpolateNumber, interpolateNumberArray, interpolateObject, interpolateOklab, interpolateRgb, isBrowser, isEmptyValue, isFaceted, isParamClause, isSlicerType, keyFields, legendSwatchWidth, lightTheme, line, linear, linearRegression, linearScale, linearToSrgb, lintSpec, literalToValue, logScale, lttbRun, makeMatcher, matchesValue, measureText, monotoneXTangents, mulberry32, niceDomain, oklabToOklch, oklabToRgb, oklchToOklab, ordinalColorScale, overlayTextToCanvasCmd, paintCanvasText, paintLegendSwatch, parseColor, parseNumberFormat, pivot, placeViews, pointScale, polygonHachureLines, prefersReducedMotion, prettyDate, profileColumns, quadIn, quadInOut, quadOut, rampFromStops, readableTextColor, recommendChart, relativeLuminance, render, renderDashboard, renderToContext, repairReport, repairSpec, resolveCurve, resolveDashboardLayout, resolveDashboardSections, resolveEmphasis, resolveEntrance, resolveFilterValues, resolveInsightOptions, resolveSketch, resolveTheme, resolveUpdate, rgbToOklab, rgbaToCss, rotatePoint, roundedRect, sampleArc, sequential, sequentialColorScale, sequentialSchemes, setMeasureContext, setStyle, sinInOut, slicerParamName, smartDate, specFields, srgbToLinear, summarize, summarizeChart, themes, tickIncrement, tickStep, ticks, timeScale, timeTickFormat, timeTicks, toHex, toPointer, tooltipEnabled, truncateTo, validateDashboard, validateSpec, wireViews, withAlpha, withSketchFont };
17235
17868
  //# sourceMappingURL=index.js.map
17236
17869
  //# sourceMappingURL=index.js.map