graphein 0.9.1 → 0.10.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.d.ts +57 -2
- package/dist/index.js +2076 -1591
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
1866
|
+
const title2 = chartTitleText(spec) ?? (spec.type === "kpi" ? spec.label : void 0);
|
|
1845
1867
|
let label = type;
|
|
1846
|
-
if (
|
|
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/
|
|
3075
|
-
var
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
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/
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
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/
|
|
3096
|
-
function
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
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
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
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
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
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
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
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/
|
|
3151
|
-
var
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
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
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
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
|
-
|
|
3218
|
-
|
|
3219
|
-
this.marks.clear();
|
|
3220
|
-
this.interaction.clear();
|
|
3221
|
-
this.overlay.replaceChildren();
|
|
3356
|
+
peek() {
|
|
3357
|
+
return this.tokens[this.pos];
|
|
3222
3358
|
}
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
this.clear();
|
|
3226
|
-
this.root.remove();
|
|
3359
|
+
next() {
|
|
3360
|
+
return this.tokens[this.pos++];
|
|
3227
3361
|
}
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
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
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
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
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
}
|
|
3269
|
-
|
|
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
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
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
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
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
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
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
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
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
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
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
|
-
|
|
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
|
|
3456
|
-
if (
|
|
3457
|
-
if (
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
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/
|
|
3465
|
-
var
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
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
|
-
|
|
3475
|
-
if (
|
|
3476
|
-
if (
|
|
3477
|
-
|
|
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
|
|
3499
|
-
return
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
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
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
var
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
];
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
"
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
];
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
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
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
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
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
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
|
-
|
|
3633
|
+
}
|
|
3634
|
+
function evaluate(node, row) {
|
|
3635
|
+
return evalNode(node, row);
|
|
3593
3636
|
}
|
|
3594
3637
|
|
|
3595
|
-
// src/spec/transform/
|
|
3596
|
-
function
|
|
3597
|
-
|
|
3598
|
-
|
|
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
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
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/
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
return
|
|
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/
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
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
|
-
|
|
3769
|
-
|
|
3674
|
+
if ("or" in pred) {
|
|
3675
|
+
const parts = pred.or.map(compilePredicate);
|
|
3676
|
+
return (row) => parts.some((p) => p(row));
|
|
3770
3677
|
}
|
|
3771
|
-
|
|
3772
|
-
|
|
3678
|
+
if ("not" in pred) {
|
|
3679
|
+
const inner = compilePredicate(pred.not);
|
|
3680
|
+
return (row) => !inner(row);
|
|
3773
3681
|
}
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
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
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
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
|
-
|
|
3786
|
-
const
|
|
3787
|
-
|
|
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
|
-
|
|
3792
|
-
const
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
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
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
const
|
|
3807
|
-
|
|
3808
|
-
|
|
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
|
-
|
|
3818
|
-
const
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
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
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
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
|
-
|
|
3845
|
-
const t =
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
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
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
if (
|
|
3884
|
-
|
|
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
|
|
3887
|
-
|
|
3888
|
-
|
|
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
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
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
|
|
3764
|
+
return out;
|
|
3897
3765
|
}
|
|
3898
3766
|
|
|
3899
|
-
// src/spec/transform/
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
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
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
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
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
if (
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
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
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
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/
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
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
|
|
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
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
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
|
-
|
|
4066
|
-
|
|
4067
|
-
const
|
|
4068
|
-
const
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
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 ("
|
|
4108
|
-
const
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
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
|
-
|
|
4115
|
-
const
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
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 ("
|
|
4123
|
-
const
|
|
4124
|
-
const
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
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 (
|
|
4133
|
-
const
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
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 ("
|
|
4140
|
-
const
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
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 ("
|
|
4147
|
-
const
|
|
4148
|
-
|
|
4149
|
-
const
|
|
4150
|
-
|
|
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 ("
|
|
4154
|
-
const
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
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
|
|
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/
|
|
4168
|
-
function
|
|
4169
|
-
|
|
4170
|
-
const
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
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
|
|
3995
|
+
return prev[n];
|
|
4177
3996
|
}
|
|
4178
|
-
|
|
4179
|
-
|
|
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
|
|
4212
|
-
const
|
|
4213
|
-
return
|
|
4214
|
-
const
|
|
4215
|
-
|
|
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
|
-
|
|
4220
|
-
|
|
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
|
|
4230
|
-
if (!
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
return
|
|
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/
|
|
4239
|
-
function
|
|
4240
|
-
if (value
|
|
4241
|
-
if (
|
|
4242
|
-
|
|
4243
|
-
|
|
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
|
|
4033
|
+
return out;
|
|
4246
4034
|
}
|
|
4247
|
-
function
|
|
4248
|
-
|
|
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
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
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
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
if (
|
|
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
|
-
|
|
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
|
|
4299
|
-
|
|
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
|
|
4304
|
-
const
|
|
4305
|
-
if (
|
|
4306
|
-
|
|
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
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
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
|
-
|
|
4335
|
-
|
|
4336
|
-
const
|
|
4337
|
-
if (
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
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;
|
|
@@ -5631,133 +5167,810 @@ function validateDashboard(spec) {
|
|
|
5631
5167
|
for (const w of res.warnings) warn(prefix(w.path), w.message);
|
|
5632
5168
|
});
|
|
5633
5169
|
}
|
|
5634
|
-
validateInteraction(spec, err, warn);
|
|
5635
|
-
const interactions = spec.interactions;
|
|
5636
|
-
if (interactions !== void 0 && interactions !== "auto" && interactions !== "none") {
|
|
5637
|
-
if (!Array.isArray(interactions)) {
|
|
5638
|
-
err("interactions", '"interactions" must be "auto", "none", or an array of links.');
|
|
5639
|
-
} else {
|
|
5640
|
-
interactions.forEach((link, i) => {
|
|
5641
|
-
if (!isObject3(link)) {
|
|
5642
|
-
err(`interactions[${i}]`, "Each link must be an object { source, target }.");
|
|
5643
|
-
return;
|
|
5644
|
-
}
|
|
5645
|
-
if (typeof link.source !== "string" || !ids.has(link.source)) {
|
|
5646
|
-
err(`interactions[${i}].source`, 'Link "source" must reference a view id.');
|
|
5647
|
-
}
|
|
5648
|
-
if (link.target === void 0) {
|
|
5649
|
-
err(`interactions[${i}].target`, 'Link needs a "target" (a view id, an array, or "*").');
|
|
5650
|
-
} else if (link.target !== "*") {
|
|
5651
|
-
const targets = Array.isArray(link.target) ? link.target : [link.target];
|
|
5652
|
-
targets.forEach((t, j) => {
|
|
5653
|
-
if (typeof t !== "string" || !ids.has(t)) {
|
|
5654
|
-
const suffix = Array.isArray(link.target) ? `[${j}]` : "";
|
|
5655
|
-
err(`interactions[${i}].target${suffix}`, 'Link "target" must reference a view id.');
|
|
5656
|
-
}
|
|
5657
|
-
});
|
|
5658
|
-
}
|
|
5659
|
-
if (link.as !== void 0 && !["highlight", "filter", "none"].includes(link.as)) {
|
|
5660
|
-
err(`interactions[${i}].as`, 'Expected "highlight", "filter", or "none".');
|
|
5661
|
-
}
|
|
5662
|
-
});
|
|
5170
|
+
validateInteraction(spec, err, warn);
|
|
5171
|
+
const interactions = spec.interactions;
|
|
5172
|
+
if (interactions !== void 0 && interactions !== "auto" && interactions !== "none") {
|
|
5173
|
+
if (!Array.isArray(interactions)) {
|
|
5174
|
+
err("interactions", '"interactions" must be "auto", "none", or an array of links.');
|
|
5175
|
+
} else {
|
|
5176
|
+
interactions.forEach((link, i) => {
|
|
5177
|
+
if (!isObject3(link)) {
|
|
5178
|
+
err(`interactions[${i}]`, "Each link must be an object { source, target }.");
|
|
5179
|
+
return;
|
|
5180
|
+
}
|
|
5181
|
+
if (typeof link.source !== "string" || !ids.has(link.source)) {
|
|
5182
|
+
err(`interactions[${i}].source`, 'Link "source" must reference a view id.');
|
|
5183
|
+
}
|
|
5184
|
+
if (link.target === void 0) {
|
|
5185
|
+
err(`interactions[${i}].target`, 'Link needs a "target" (a view id, an array, or "*").');
|
|
5186
|
+
} else if (link.target !== "*") {
|
|
5187
|
+
const targets = Array.isArray(link.target) ? link.target : [link.target];
|
|
5188
|
+
targets.forEach((t, j) => {
|
|
5189
|
+
if (typeof t !== "string" || !ids.has(t)) {
|
|
5190
|
+
const suffix = Array.isArray(link.target) ? `[${j}]` : "";
|
|
5191
|
+
err(`interactions[${i}].target${suffix}`, 'Link "target" must reference a view id.');
|
|
5192
|
+
}
|
|
5193
|
+
});
|
|
5194
|
+
}
|
|
5195
|
+
if (link.as !== void 0 && !["highlight", "filter", "none"].includes(link.as)) {
|
|
5196
|
+
err(`interactions[${i}].as`, 'Expected "highlight", "filter", or "none".');
|
|
5197
|
+
}
|
|
5198
|
+
});
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5201
|
+
if (spec.subtitle !== void 0 && typeof spec.subtitle !== "string") {
|
|
5202
|
+
err("subtitle", '"subtitle" must be a string.');
|
|
5203
|
+
}
|
|
5204
|
+
const layout2 = spec.layout;
|
|
5205
|
+
if (layout2 !== void 0) {
|
|
5206
|
+
if (!isObject3(layout2)) {
|
|
5207
|
+
err("layout", '"layout" must be an object { cols?, rowHeight?, gap?, breakpoints?, navigators?, sections? }.');
|
|
5208
|
+
} else {
|
|
5209
|
+
for (const k of ["cols", "rowHeight", "gap", "maxWidth", "padding"]) {
|
|
5210
|
+
const n = layout2[k];
|
|
5211
|
+
if (n !== void 0 && (typeof n !== "number" || !Number.isFinite(n) || n < 0)) {
|
|
5212
|
+
err(`layout.${k}`, `"${k}" must be a non-negative number.`);
|
|
5213
|
+
}
|
|
5214
|
+
}
|
|
5215
|
+
if (layout2.navigators !== void 0 && layout2.navigators !== "top" && layout2.navigators !== "inline") {
|
|
5216
|
+
err("layout.navigators", 'Expected "top" or "inline".');
|
|
5217
|
+
}
|
|
5218
|
+
if (layout2.preset !== void 0 && !["auto", "kpi-first", "sidebar"].includes(layout2.preset)) {
|
|
5219
|
+
err("layout.preset", 'Expected "auto", "kpi-first", or "sidebar".');
|
|
5220
|
+
}
|
|
5221
|
+
if (layout2.density !== void 0 && !["compact", "standard", "comfortable"].includes(layout2.density)) {
|
|
5222
|
+
err("layout.density", 'Expected "compact", "standard", or "comfortable".');
|
|
5223
|
+
}
|
|
5224
|
+
if (layout2.breakpoints !== void 0) {
|
|
5225
|
+
if (!Array.isArray(layout2.breakpoints)) {
|
|
5226
|
+
err("layout.breakpoints", '"breakpoints" must be an array of { maxWidth, cols }.');
|
|
5227
|
+
} else {
|
|
5228
|
+
layout2.breakpoints.forEach((bp, i) => {
|
|
5229
|
+
if (!isObject3(bp)) {
|
|
5230
|
+
err(`layout.breakpoints[${i}]`, "Each breakpoint must be an object { maxWidth, cols }.");
|
|
5231
|
+
return;
|
|
5232
|
+
}
|
|
5233
|
+
for (const k of ["maxWidth", "cols"]) {
|
|
5234
|
+
const n = bp[k];
|
|
5235
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n < 1) {
|
|
5236
|
+
err(`layout.breakpoints[${i}].${k}`, `"${k}" must be a positive number.`);
|
|
5237
|
+
}
|
|
5238
|
+
}
|
|
5239
|
+
});
|
|
5240
|
+
}
|
|
5241
|
+
}
|
|
5242
|
+
if (layout2.sections !== void 0) {
|
|
5243
|
+
if (!Array.isArray(layout2.sections)) {
|
|
5244
|
+
err("layout.sections", '"sections" must be an array of dashboard sections.');
|
|
5245
|
+
} else {
|
|
5246
|
+
const sectionViewIds = /* @__PURE__ */ new Set();
|
|
5247
|
+
layout2.sections.forEach((section, i) => {
|
|
5248
|
+
if (!isObject3(section)) {
|
|
5249
|
+
err(`layout.sections[${i}]`, "Each section must be an object { views }.");
|
|
5250
|
+
return;
|
|
5251
|
+
}
|
|
5252
|
+
for (const k of ["id", "title", "subtitle", "background"]) {
|
|
5253
|
+
if (section[k] !== void 0 && typeof section[k] !== "string") {
|
|
5254
|
+
err(`layout.sections[${i}].${k}`, `"${k}" must be a string.`);
|
|
5255
|
+
}
|
|
5256
|
+
}
|
|
5257
|
+
for (const k of ["cols", "rowHeight"]) {
|
|
5258
|
+
const n = section[k];
|
|
5259
|
+
if (n !== void 0 && (typeof n !== "number" || !Number.isFinite(n) || n < 1)) {
|
|
5260
|
+
err(`layout.sections[${i}].${k}`, `"${k}" must be a positive number.`);
|
|
5261
|
+
}
|
|
5262
|
+
}
|
|
5263
|
+
if (section.collapsed !== void 0 && typeof section.collapsed !== "boolean") {
|
|
5264
|
+
err(`layout.sections[${i}].collapsed`, '"collapsed" must be a boolean.');
|
|
5265
|
+
}
|
|
5266
|
+
if (!Array.isArray(section.views)) {
|
|
5267
|
+
err(`layout.sections[${i}].views`, '"views" must be an array of view ids.');
|
|
5268
|
+
} else {
|
|
5269
|
+
section.views.forEach((id, j) => {
|
|
5270
|
+
const path = `layout.sections[${i}].views[${j}]`;
|
|
5271
|
+
if (typeof id !== "string" || id === "") {
|
|
5272
|
+
err(path, "Section view references must be non-empty strings.");
|
|
5273
|
+
} else if (!ids.has(id)) {
|
|
5274
|
+
err(path, `Section view "${id}" must reference an existing view id.`);
|
|
5275
|
+
} else if (sectionViewIds.has(id)) {
|
|
5276
|
+
err(path, `View "${id}" is already assigned to another section.`);
|
|
5277
|
+
} else {
|
|
5278
|
+
sectionViewIds.add(id);
|
|
5279
|
+
}
|
|
5280
|
+
});
|
|
5281
|
+
}
|
|
5282
|
+
});
|
|
5283
|
+
}
|
|
5284
|
+
}
|
|
5285
|
+
}
|
|
5286
|
+
}
|
|
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
|
+
}
|
|
5913
|
+
}
|
|
5914
|
+
function withSketchFont(tokens) {
|
|
5915
|
+
return {
|
|
5916
|
+
...tokens,
|
|
5917
|
+
font: {
|
|
5918
|
+
...tokens.font,
|
|
5919
|
+
family: `${SKETCH_FONT_FAMILY}`
|
|
5663
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);
|
|
5664
5930
|
}
|
|
5665
|
-
|
|
5666
|
-
|
|
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";
|
|
5667
5943
|
}
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
err("layout.preset", 'Expected "auto", "kpi-first", or "sidebar".');
|
|
5684
|
-
}
|
|
5685
|
-
if (layout2.density !== void 0 && !["compact", "standard", "comfortable"].includes(layout2.density)) {
|
|
5686
|
-
err("layout.density", 'Expected "compact", "standard", or "comfortable".');
|
|
5687
|
-
}
|
|
5688
|
-
if (layout2.breakpoints !== void 0) {
|
|
5689
|
-
if (!Array.isArray(layout2.breakpoints)) {
|
|
5690
|
-
err("layout.breakpoints", '"breakpoints" must be an array of { maxWidth, cols }.');
|
|
5691
|
-
} else {
|
|
5692
|
-
layout2.breakpoints.forEach((bp, i) => {
|
|
5693
|
-
if (!isObject3(bp)) {
|
|
5694
|
-
err(`layout.breakpoints[${i}]`, "Each breakpoint must be an object { maxWidth, cols }.");
|
|
5695
|
-
return;
|
|
5696
|
-
}
|
|
5697
|
-
for (const k of ["maxWidth", "cols"]) {
|
|
5698
|
-
const n = bp[k];
|
|
5699
|
-
if (typeof n !== "number" || !Number.isFinite(n) || n < 1) {
|
|
5700
|
-
err(`layout.breakpoints[${i}].${k}`, `"${k}" must be a positive number.`);
|
|
5701
|
-
}
|
|
5702
|
-
}
|
|
5703
|
-
});
|
|
5704
|
-
}
|
|
5705
|
-
}
|
|
5706
|
-
if (layout2.sections !== void 0) {
|
|
5707
|
-
if (!Array.isArray(layout2.sections)) {
|
|
5708
|
-
err("layout.sections", '"sections" must be an array of dashboard sections.');
|
|
5709
|
-
} else {
|
|
5710
|
-
const sectionViewIds = /* @__PURE__ */ new Set();
|
|
5711
|
-
layout2.sections.forEach((section, i) => {
|
|
5712
|
-
if (!isObject3(section)) {
|
|
5713
|
-
err(`layout.sections[${i}]`, "Each section must be an object { views }.");
|
|
5714
|
-
return;
|
|
5715
|
-
}
|
|
5716
|
-
for (const k of ["id", "title", "subtitle", "background"]) {
|
|
5717
|
-
if (section[k] !== void 0 && typeof section[k] !== "string") {
|
|
5718
|
-
err(`layout.sections[${i}].${k}`, `"${k}" must be a string.`);
|
|
5719
|
-
}
|
|
5720
|
-
}
|
|
5721
|
-
for (const k of ["cols", "rowHeight"]) {
|
|
5722
|
-
const n = section[k];
|
|
5723
|
-
if (n !== void 0 && (typeof n !== "number" || !Number.isFinite(n) || n < 1)) {
|
|
5724
|
-
err(`layout.sections[${i}].${k}`, `"${k}" must be a positive number.`);
|
|
5725
|
-
}
|
|
5726
|
-
}
|
|
5727
|
-
if (section.collapsed !== void 0 && typeof section.collapsed !== "boolean") {
|
|
5728
|
-
err(`layout.sections[${i}].collapsed`, '"collapsed" must be a boolean.');
|
|
5729
|
-
}
|
|
5730
|
-
if (!Array.isArray(section.views)) {
|
|
5731
|
-
err(`layout.sections[${i}].views`, '"views" must be an array of view ids.');
|
|
5732
|
-
} else {
|
|
5733
|
-
section.views.forEach((id, j) => {
|
|
5734
|
-
const path = `layout.sections[${i}].views[${j}]`;
|
|
5735
|
-
if (typeof id !== "string" || id === "") {
|
|
5736
|
-
err(path, "Section view references must be non-empty strings.");
|
|
5737
|
-
} else if (!ids.has(id)) {
|
|
5738
|
-
err(path, `Section view "${id}" must reference an existing view id.`);
|
|
5739
|
-
} else if (sectionViewIds.has(id)) {
|
|
5740
|
-
err(path, `View "${id}" is already assigned to another section.`);
|
|
5741
|
-
} else {
|
|
5742
|
-
sectionViewIds.add(id);
|
|
5743
|
-
}
|
|
5744
|
-
});
|
|
5745
|
-
}
|
|
5746
|
-
});
|
|
5747
|
-
}
|
|
5748
|
-
}
|
|
5749
|
-
}
|
|
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++;
|
|
5750
5959
|
}
|
|
5751
|
-
|
|
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";
|
|
5752
5964
|
}
|
|
5753
|
-
function
|
|
5754
|
-
const
|
|
5755
|
-
|
|
5756
|
-
const
|
|
5757
|
-
|
|
5758
|
-
${detail}`);
|
|
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);
|
|
5759
5970
|
}
|
|
5760
|
-
|
|
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
|
|
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}|${
|
|
6003
|
+
return hashString(`${spec.type}|${title2}|${dataLen}|${firstRow}`) || 1;
|
|
5791
6004
|
}
|
|
5792
6005
|
function resolveSketch(spec) {
|
|
5793
6006
|
const sketch = spec.sketch;
|
|
@@ -6242,25 +6455,25 @@ function drawAxisTitles(surface, model) {
|
|
|
6242
6455
|
function drawTitle(surface, frame, spec, tokens) {
|
|
6243
6456
|
if (!frame.titleRect) return;
|
|
6244
6457
|
const f = tokens.font;
|
|
6245
|
-
const
|
|
6458
|
+
const title2 = typeof spec.title === "string" ? { text: spec.title } : spec.title ?? {};
|
|
6246
6459
|
const align = typeof spec.title === "object" && spec.title?.align || "left";
|
|
6247
6460
|
const titleFont = fontString(f.size.title, f.family, f.weight.bold);
|
|
6248
6461
|
addText(surface, titleFont, {
|
|
6249
6462
|
left: frame.titleRect.x,
|
|
6250
6463
|
top: frame.titleRect.y,
|
|
6251
6464
|
width: frame.titleRect.width,
|
|
6252
|
-
text:
|
|
6465
|
+
text: title2.text ?? "",
|
|
6253
6466
|
color: tokens.color.text,
|
|
6254
6467
|
size: f.size.title,
|
|
6255
6468
|
weight: f.weight.bold,
|
|
6256
6469
|
align
|
|
6257
6470
|
});
|
|
6258
|
-
if (frame.subtitleRect &&
|
|
6471
|
+
if (frame.subtitleRect && title2.subtitle) {
|
|
6259
6472
|
addText(surface, fontString(f.size.small, f.family, f.weight.normal), {
|
|
6260
6473
|
left: frame.subtitleRect.x,
|
|
6261
6474
|
top: frame.subtitleRect.y,
|
|
6262
6475
|
width: frame.subtitleRect.width,
|
|
6263
|
-
text:
|
|
6476
|
+
text: title2.subtitle,
|
|
6264
6477
|
color: tokens.color.textMuted,
|
|
6265
6478
|
size: f.size.small,
|
|
6266
6479
|
align
|
|
@@ -6418,6 +6631,10 @@ function formatNumericTick(value, userFormat) {
|
|
|
6418
6631
|
return formatNumber2(value, ",");
|
|
6419
6632
|
}
|
|
6420
6633
|
var DEFAULT_PADDING = { top: 12, right: 16, bottom: 12, left: 12 };
|
|
6634
|
+
var LEGEND_SWATCH2 = 11;
|
|
6635
|
+
function legendInteractive(spec) {
|
|
6636
|
+
return typeof spec.legend === "object" && spec.legend.interactive === true;
|
|
6637
|
+
}
|
|
6421
6638
|
function buildCartesianModel(spec, tokens, opts) {
|
|
6422
6639
|
const data = spec.data ?? [];
|
|
6423
6640
|
const enc = spec.encoding;
|
|
@@ -6428,7 +6645,7 @@ function buildCartesianModel(spec, tokens, opts) {
|
|
|
6428
6645
|
const seriesField = resolveSeriesField(spec);
|
|
6429
6646
|
const rawSeries = groupBySeries(data, seriesField);
|
|
6430
6647
|
const seriesKeys = rawSeries.map((s) => s.key);
|
|
6431
|
-
const palette = tokens.color.palette;
|
|
6648
|
+
const palette = Array.isArray(spec.palette) ? spec.palette : typeof spec.palette === "string" ? categorical(spec.palette) : tokens.color.palette;
|
|
6432
6649
|
const colorScale = ordinalColorScale({ domain: seriesKeys, palette });
|
|
6433
6650
|
const colorCache = /* @__PURE__ */ new Map();
|
|
6434
6651
|
const sharedColor = opts.shared?.colorOf;
|
|
@@ -6592,6 +6809,21 @@ function buildCartesianModel(spec, tokens, opts) {
|
|
|
6592
6809
|
xAxis: { show: xShow, labels: xLabels, title: resolveAxisTitle(enc.x, xAxisCfg), edgeAnchored: !categories, labelAngle: xAxisCfg.labelAngle },
|
|
6593
6810
|
yAxis: { show: yShow, labels: yLabels, title: resolveAxisTitle(enc.y, yAxisCfg) }
|
|
6594
6811
|
});
|
|
6812
|
+
const legendHits = legendInteractive(spec) && seriesField && frame.legendItems ? frame.legendItems.map((item, i) => {
|
|
6813
|
+
const rowH = Math.max(LEGEND_SWATCH2, Math.round(tokens.font.size.small * 1.35));
|
|
6814
|
+
const midY = item.y + LEGEND_SWATCH2 / 2;
|
|
6815
|
+
return {
|
|
6816
|
+
rect: {
|
|
6817
|
+
x: item.x,
|
|
6818
|
+
y: midY - rowH / 2,
|
|
6819
|
+
width: item.width,
|
|
6820
|
+
height: rowH
|
|
6821
|
+
},
|
|
6822
|
+
key: series[i]?.key ?? item.label,
|
|
6823
|
+
value: series[i]?.value ?? item.label,
|
|
6824
|
+
label: item.label
|
|
6825
|
+
};
|
|
6826
|
+
}) : void 0;
|
|
6595
6827
|
const plot = frame.plot;
|
|
6596
6828
|
const yRange = [plot.y + plot.height, plot.y];
|
|
6597
6829
|
const yScale = yIsLog ? logScale({ domain: yDomain, range: yRange, base: yBase, clamp: yClamp }) : linearScale({ domain: yDomain, range: yRange, clamp: yClamp });
|
|
@@ -6628,6 +6860,7 @@ function buildCartesianModel(spec, tokens, opts) {
|
|
|
6628
6860
|
xTicks,
|
|
6629
6861
|
yTicks,
|
|
6630
6862
|
colorOf,
|
|
6863
|
+
legendHits,
|
|
6631
6864
|
sketch: resolveSketch(spec)
|
|
6632
6865
|
};
|
|
6633
6866
|
}
|
|
@@ -7137,6 +7370,8 @@ function drawBar(surface, model) {
|
|
|
7137
7370
|
}
|
|
7138
7371
|
|
|
7139
7372
|
// src/charts/scatter.ts
|
|
7373
|
+
var QUANTIZE_MIN_POINTS = 4e3;
|
|
7374
|
+
var QUANTIZE_AREA_DIVISOR = 4;
|
|
7140
7375
|
function isMissing(value) {
|
|
7141
7376
|
return value == null || value === "";
|
|
7142
7377
|
}
|
|
@@ -7146,6 +7381,35 @@ function isInvalidPositionValue(value) {
|
|
|
7146
7381
|
function fixedRadius() {
|
|
7147
7382
|
return 3.75;
|
|
7148
7383
|
}
|
|
7384
|
+
function shouldQuantize(count, plot, hasSize) {
|
|
7385
|
+
return !hasSize && count > QUANTIZE_MIN_POINTS && count > plot.width * plot.height / QUANTIZE_AREA_DIVISOR;
|
|
7386
|
+
}
|
|
7387
|
+
function quantizePoints(points) {
|
|
7388
|
+
if (points.length === 0) return [];
|
|
7389
|
+
let minX = Infinity;
|
|
7390
|
+
let maxX = -Infinity;
|
|
7391
|
+
let minY = Infinity;
|
|
7392
|
+
let maxY = -Infinity;
|
|
7393
|
+
for (const p of points) {
|
|
7394
|
+
if (p.x < minX) minX = p.x;
|
|
7395
|
+
if (p.x > maxX) maxX = p.x;
|
|
7396
|
+
if (p.y < minY) minY = p.y;
|
|
7397
|
+
if (p.y > maxY) maxY = p.y;
|
|
7398
|
+
}
|
|
7399
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7400
|
+
const keep = [];
|
|
7401
|
+
for (const p of points) {
|
|
7402
|
+
const extentPoint = p.x === minX || p.x === maxX || p.y === minY || p.y === maxY;
|
|
7403
|
+
const ix = Math.round(p.x);
|
|
7404
|
+
const iy = Math.round(p.y);
|
|
7405
|
+
const key = `${p.seriesKey}|${ix}|${iy}`;
|
|
7406
|
+
if (extentPoint || !seen.has(key)) {
|
|
7407
|
+
keep.push(p);
|
|
7408
|
+
seen.add(key);
|
|
7409
|
+
}
|
|
7410
|
+
}
|
|
7411
|
+
return keep;
|
|
7412
|
+
}
|
|
7149
7413
|
function radiusScale(rows, field, plotSize) {
|
|
7150
7414
|
const rMin = 3;
|
|
7151
7415
|
const rMax = clamp(plotSize * 0.05, 10, 26);
|
|
@@ -7188,18 +7452,22 @@ function drawScatter(surface, model) {
|
|
|
7188
7452
|
y: py,
|
|
7189
7453
|
r: readSize ? radius(readSize(row)) : radius(),
|
|
7190
7454
|
color: series.color,
|
|
7191
|
-
alpha: rowAlpha(model.emphasis, row)
|
|
7455
|
+
alpha: rowAlpha(model.emphasis, row),
|
|
7456
|
+
seriesKey: series.key
|
|
7192
7457
|
});
|
|
7193
7458
|
}
|
|
7194
7459
|
}
|
|
7195
|
-
|
|
7460
|
+
const originalCount = points.length;
|
|
7461
|
+
const drawPoints = shouldQuantize(originalCount, plot, !!sizeField) ? quantizePoints(points) : points;
|
|
7462
|
+
model.markStats = { drawn: drawPoints.length, original: originalCount };
|
|
7463
|
+
if (sizeField) drawPoints.sort((a, b) => b.r - a.r);
|
|
7196
7464
|
ctx.save();
|
|
7197
7465
|
ctx.beginPath();
|
|
7198
7466
|
ctx.rect(plot.x, plot.y, plot.width, plot.height);
|
|
7199
7467
|
ctx.clip();
|
|
7200
7468
|
if (model.sketch) {
|
|
7201
7469
|
const pen = new RoughPen(ctx, model.sketch);
|
|
7202
|
-
for (const point of
|
|
7470
|
+
for (const point of drawPoints) {
|
|
7203
7471
|
const prev = ctx.globalAlpha;
|
|
7204
7472
|
if (point.alpha !== 1) ctx.globalAlpha = point.alpha;
|
|
7205
7473
|
pen.circle(point.x, point.y, point.r, {
|
|
@@ -7215,7 +7483,7 @@ function drawScatter(surface, model) {
|
|
|
7215
7483
|
}
|
|
7216
7484
|
ctx.lineWidth = 1;
|
|
7217
7485
|
ctx.strokeStyle = tokens.color.background;
|
|
7218
|
-
for (const point of
|
|
7486
|
+
for (const point of drawPoints) {
|
|
7219
7487
|
ctx.beginPath();
|
|
7220
7488
|
ctx.arc(point.x, point.y, point.r, 0, Math.PI * 2);
|
|
7221
7489
|
ctx.fillStyle = point.color;
|
|
@@ -7473,10 +7741,10 @@ function buildBoxInteraction(model) {
|
|
|
7473
7741
|
|
|
7474
7742
|
// src/charts/chrome.ts
|
|
7475
7743
|
var CHROME_PAD = { top: 14, right: 16, bottom: 14, left: 16 };
|
|
7476
|
-
function resolveTitle2(
|
|
7477
|
-
if (typeof
|
|
7478
|
-
if (
|
|
7479
|
-
const t =
|
|
7744
|
+
function resolveTitle2(title2) {
|
|
7745
|
+
if (typeof title2 === "string") return { text: title2, align: "left" };
|
|
7746
|
+
if (title2 && typeof title2 === "object") {
|
|
7747
|
+
const t = title2;
|
|
7480
7748
|
return { text: t.text, subtitle: t.subtitle, align: t.align ?? "left" };
|
|
7481
7749
|
}
|
|
7482
7750
|
return { align: "left" };
|
|
@@ -7531,9 +7799,9 @@ function addOverlayText(surface, tokens, o) {
|
|
|
7531
7799
|
surface.overlay.appendChild(el);
|
|
7532
7800
|
return el;
|
|
7533
7801
|
}
|
|
7534
|
-
function drawTitleBlock(surface, tokens, size,
|
|
7802
|
+
function drawTitleBlock(surface, tokens, size, title2) {
|
|
7535
7803
|
const f = tokens.font;
|
|
7536
|
-
const t = resolveTitle2(
|
|
7804
|
+
const t = resolveTitle2(title2);
|
|
7537
7805
|
let top = CHROME_PAD.top;
|
|
7538
7806
|
const innerWidth = size.width - CHROME_PAD.left - CHROME_PAD.right;
|
|
7539
7807
|
if (t.text) {
|
|
@@ -9912,11 +10180,11 @@ function buildHeader(opts) {
|
|
|
9912
10180
|
thead.appendChild(tr);
|
|
9913
10181
|
return thead;
|
|
9914
10182
|
}
|
|
9915
|
-
function makeHeaderCell(opts,
|
|
10183
|
+
function makeHeaderCell(opts, title2, colIndex, align, rowIndex = 0) {
|
|
9916
10184
|
const th = document.createElement("th");
|
|
9917
10185
|
const metrics = densityMetrics(opts.density);
|
|
9918
10186
|
th.scope = "col";
|
|
9919
|
-
th.textContent =
|
|
10187
|
+
th.textContent = title2;
|
|
9920
10188
|
const isSorted = colIndex != null && opts.sortState?.col === colIndex;
|
|
9921
10189
|
if (isSorted) th.setAttribute("aria-sort", opts.sortState?.dir === "desc" ? "descending" : "ascending");
|
|
9922
10190
|
setCellBaseStyles(th, opts.tokens, align ?? "left", opts.stickyHeader !== false, metrics.padding);
|
|
@@ -9948,7 +10216,7 @@ function makeHeaderCell(opts, title, colIndex, align, rowIndex = 0) {
|
|
|
9948
10216
|
font: "inherit"
|
|
9949
10217
|
});
|
|
9950
10218
|
const label = document.createElement("span");
|
|
9951
|
-
label.textContent =
|
|
10219
|
+
label.textContent = title2;
|
|
9952
10220
|
label.style.overflow = "hidden";
|
|
9953
10221
|
label.style.textOverflow = "ellipsis";
|
|
9954
10222
|
const glyph = document.createElement("span");
|
|
@@ -10777,10 +11045,10 @@ function prepareMatrix(spec) {
|
|
|
10777
11045
|
},
|
|
10778
11046
|
...leafValueColumns.map(({ leaf, valueIndex }) => {
|
|
10779
11047
|
const valueDef = spec.values[valueIndex];
|
|
10780
|
-
const
|
|
11048
|
+
const title2 = spec.values.length > 1 ? valueDef.label ?? valueDef.field : leaf.key;
|
|
10781
11049
|
return {
|
|
10782
11050
|
key: `${leaf.path.join(PATH_SEPARATOR2)}:${valueIndex}`,
|
|
10783
|
-
title,
|
|
11051
|
+
title: title2,
|
|
10784
11052
|
align: "right",
|
|
10785
11053
|
type: "quantitative",
|
|
10786
11054
|
format: valueDef.showAs && valueDef.showAs !== "value" ? ".1%" : valueDef.format,
|
|
@@ -10978,8 +11246,8 @@ function mountSlicerShell(surface, tokens, size, opts = {}) {
|
|
|
10978
11246
|
color: c.text,
|
|
10979
11247
|
font: fontString(tokens.font.size.base, tokens.font.family, tokens.font.weight.normal)
|
|
10980
11248
|
});
|
|
10981
|
-
const
|
|
10982
|
-
const labelText2 =
|
|
11249
|
+
const title2 = resolveTitle2(opts.title);
|
|
11250
|
+
const labelText2 = title2.text ?? opts.label;
|
|
10983
11251
|
const header = document.createElement("div");
|
|
10984
11252
|
Object.assign(header.style, {
|
|
10985
11253
|
display: "flex",
|
|
@@ -10993,16 +11261,16 @@ function mountSlicerShell(surface, tokens, size, opts = {}) {
|
|
|
10993
11261
|
labelEl.textContent = labelText2 ?? "";
|
|
10994
11262
|
Object.assign(labelEl.style, {
|
|
10995
11263
|
font: fontString(
|
|
10996
|
-
|
|
11264
|
+
title2.text ? tokens.font.size.large : tokens.font.size.small,
|
|
10997
11265
|
tokens.font.family,
|
|
10998
|
-
|
|
11266
|
+
title2.text ? tokens.font.weight.bold : tokens.font.weight.medium
|
|
10999
11267
|
),
|
|
11000
|
-
color:
|
|
11268
|
+
color: title2.text ? c.text : c.textMuted,
|
|
11001
11269
|
whiteSpace: "nowrap",
|
|
11002
11270
|
overflow: "hidden",
|
|
11003
11271
|
textOverflow: "ellipsis"
|
|
11004
11272
|
});
|
|
11005
|
-
if (
|
|
11273
|
+
if (title2.subtitle) labelEl.title = title2.subtitle;
|
|
11006
11274
|
const clearSlot = document.createElement("div");
|
|
11007
11275
|
clearSlot.style.flex = "0 0 auto";
|
|
11008
11276
|
header.appendChild(labelEl);
|
|
@@ -11104,8 +11372,8 @@ function makePopover(tokens) {
|
|
|
11104
11372
|
const c = tokens.color;
|
|
11105
11373
|
const pop = document.createElement("div");
|
|
11106
11374
|
Object.assign(pop.style, {
|
|
11107
|
-
position: "
|
|
11108
|
-
zIndex: "
|
|
11375
|
+
position: "fixed",
|
|
11376
|
+
zIndex: "2147483000",
|
|
11109
11377
|
background: c.surface,
|
|
11110
11378
|
border: `1px solid ${c.border}`,
|
|
11111
11379
|
borderRadius: `${tokens.radius.md}px`,
|
|
@@ -11322,14 +11590,26 @@ function makeDualSlider(tokens, opts) {
|
|
|
11322
11590
|
}
|
|
11323
11591
|
};
|
|
11324
11592
|
}
|
|
11325
|
-
function positionPopover(pop, anchor
|
|
11593
|
+
function positionPopover(pop, anchor) {
|
|
11326
11594
|
const a = anchor.getBoundingClientRect();
|
|
11327
|
-
const
|
|
11328
|
-
|
|
11329
|
-
|
|
11595
|
+
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
11596
|
+
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
11597
|
+
const margin = 8;
|
|
11598
|
+
const gap = 4;
|
|
11330
11599
|
pop.style.width = `${a.width}px`;
|
|
11331
|
-
const
|
|
11332
|
-
pop.style.
|
|
11600
|
+
const left = Math.min(Math.max(margin, a.left), Math.max(margin, vw - a.width - margin));
|
|
11601
|
+
pop.style.left = `${left}px`;
|
|
11602
|
+
const spaceBelow = vh - a.bottom - margin;
|
|
11603
|
+
const spaceAbove = a.top - margin;
|
|
11604
|
+
if (spaceBelow >= 160 || spaceBelow >= spaceAbove) {
|
|
11605
|
+
pop.style.top = `${a.bottom + gap}px`;
|
|
11606
|
+
pop.style.bottom = "";
|
|
11607
|
+
pop.style.maxHeight = `${Math.max(80, spaceBelow - gap)}px`;
|
|
11608
|
+
} else {
|
|
11609
|
+
pop.style.top = "";
|
|
11610
|
+
pop.style.bottom = `${vh - a.top + gap}px`;
|
|
11611
|
+
pop.style.maxHeight = `${Math.max(80, spaceAbove - gap)}px`;
|
|
11612
|
+
}
|
|
11333
11613
|
}
|
|
11334
11614
|
function clamp7(v, lo, hi) {
|
|
11335
11615
|
return v < lo ? lo : v > hi ? hi : v;
|
|
@@ -11390,12 +11670,14 @@ function emptyNotice(host, message, color) {
|
|
|
11390
11670
|
}
|
|
11391
11671
|
|
|
11392
11672
|
// src/charts/slicers/dropdown.ts
|
|
11673
|
+
var openMenus = /* @__PURE__ */ new WeakMap();
|
|
11393
11674
|
function drawDropdown(surface, spec, tokens, size, context) {
|
|
11394
11675
|
const s = spec;
|
|
11395
11676
|
if (surface.headless) {
|
|
11396
11677
|
drawDropdownCanvas(surface, s, tokens, size, context);
|
|
11397
11678
|
return;
|
|
11398
11679
|
}
|
|
11680
|
+
openMenus.get(surface)?.();
|
|
11399
11681
|
const shell = mountSlicerShell(surface, tokens, size, {
|
|
11400
11682
|
title: s.title,
|
|
11401
11683
|
label: slicerLabel(s)
|
|
@@ -11429,16 +11711,34 @@ function drawDropdown(surface, spec, tokens, size, context) {
|
|
|
11429
11711
|
emit();
|
|
11430
11712
|
};
|
|
11431
11713
|
let pop = null;
|
|
11714
|
+
const reposition = () => {
|
|
11715
|
+
if (!pop) return;
|
|
11716
|
+
if (!trigger.isConnected) {
|
|
11717
|
+
closeMenu();
|
|
11718
|
+
return;
|
|
11719
|
+
}
|
|
11720
|
+
positionPopover(pop, trigger);
|
|
11721
|
+
};
|
|
11432
11722
|
const closeMenu = () => {
|
|
11433
11723
|
if (pop) {
|
|
11434
11724
|
pop.remove();
|
|
11435
11725
|
pop = null;
|
|
11436
11726
|
document.removeEventListener("pointerdown", onOutside, true);
|
|
11727
|
+
document.removeEventListener("keydown", onKey, true);
|
|
11728
|
+
window.removeEventListener("scroll", reposition, true);
|
|
11729
|
+
window.removeEventListener("resize", reposition);
|
|
11730
|
+
openMenus.delete(surface);
|
|
11437
11731
|
}
|
|
11438
11732
|
};
|
|
11439
11733
|
const onOutside = (e) => {
|
|
11440
11734
|
if (pop && !pop.contains(e.target) && !trigger.contains(e.target)) closeMenu();
|
|
11441
11735
|
};
|
|
11736
|
+
const onKey = (e) => {
|
|
11737
|
+
if (e.key === "Escape" && pop) {
|
|
11738
|
+
closeMenu();
|
|
11739
|
+
trigger.focus();
|
|
11740
|
+
}
|
|
11741
|
+
};
|
|
11442
11742
|
const openMenu = () => {
|
|
11443
11743
|
if (pop) {
|
|
11444
11744
|
closeMenu();
|
|
@@ -11469,9 +11769,13 @@ function drawDropdown(surface, spec, tokens, size, context) {
|
|
|
11469
11769
|
});
|
|
11470
11770
|
pop.appendChild(row);
|
|
11471
11771
|
}
|
|
11472
|
-
|
|
11473
|
-
positionPopover(pop, trigger
|
|
11772
|
+
document.body.appendChild(pop);
|
|
11773
|
+
positionPopover(pop, trigger);
|
|
11774
|
+
openMenus.set(surface, closeMenu);
|
|
11474
11775
|
document.addEventListener("pointerdown", onOutside, true);
|
|
11776
|
+
document.addEventListener("keydown", onKey, true);
|
|
11777
|
+
window.addEventListener("scroll", reposition, true);
|
|
11778
|
+
window.addEventListener("resize", reposition);
|
|
11475
11779
|
};
|
|
11476
11780
|
trigger.addEventListener("click", openMenu);
|
|
11477
11781
|
refreshChrome();
|
|
@@ -12131,10 +12435,12 @@ function tooltipEnabled(spec) {
|
|
|
12131
12435
|
return true;
|
|
12132
12436
|
}
|
|
12133
12437
|
function buildCartesianInteraction(model) {
|
|
12134
|
-
if (!tooltipEnabled(model.spec))
|
|
12135
|
-
|
|
12136
|
-
|
|
12137
|
-
|
|
12438
|
+
if (!tooltipEnabled(model.spec) || model.series.length === 0) {
|
|
12439
|
+
return model.legendHits?.length ? { region: model.plot, hitTest: () => null, legendHits: model.legendHits } : null;
|
|
12440
|
+
}
|
|
12441
|
+
const interaction = model.spec.type === "scatter" ? scatterInteraction(model) : indexInteraction(model);
|
|
12442
|
+
if (interaction) interaction.legendHits = model.legendHits;
|
|
12443
|
+
return interaction;
|
|
12138
12444
|
}
|
|
12139
12445
|
function inside(r, px, py, pad3 = 0) {
|
|
12140
12446
|
return px >= r.x - pad3 && px <= r.x + r.width + pad3 && py >= r.y - pad3 && py <= r.y + r.height + pad3;
|
|
@@ -12285,17 +12591,35 @@ function scatterInteraction(model) {
|
|
|
12285
12591
|
}
|
|
12286
12592
|
}
|
|
12287
12593
|
if (points.length === 0) return null;
|
|
12594
|
+
const cell = HIT_RADIUS;
|
|
12595
|
+
const grid = /* @__PURE__ */ new Map();
|
|
12596
|
+
for (let i = 0; i < points.length; i++) {
|
|
12597
|
+
const col = Math.floor(points[i].px / cell);
|
|
12598
|
+
const row = Math.floor(points[i].py / cell);
|
|
12599
|
+
const key = `${col},${row}`;
|
|
12600
|
+
const bucket = grid.get(key);
|
|
12601
|
+
if (bucket) bucket.push(i);
|
|
12602
|
+
else grid.set(key, [i]);
|
|
12603
|
+
}
|
|
12288
12604
|
const nearest = (px, py) => {
|
|
12289
12605
|
if (!inside(plot, px, py, HIT_RADIUS)) return -1;
|
|
12290
12606
|
let best = -1;
|
|
12291
12607
|
let bestSq = HIT_RADIUS * HIT_RADIUS;
|
|
12292
|
-
|
|
12293
|
-
|
|
12294
|
-
|
|
12295
|
-
|
|
12296
|
-
|
|
12297
|
-
|
|
12298
|
-
|
|
12608
|
+
const col = Math.floor(px / cell);
|
|
12609
|
+
const row = Math.floor(py / cell);
|
|
12610
|
+
for (let dc = -1; dc <= 1; dc++) {
|
|
12611
|
+
for (let dr = -1; dr <= 1; dr++) {
|
|
12612
|
+
const bucket = grid.get(`${col + dc},${row + dr}`);
|
|
12613
|
+
if (!bucket) continue;
|
|
12614
|
+
for (const i of bucket) {
|
|
12615
|
+
const dx = points[i].px - px;
|
|
12616
|
+
const dy = points[i].py - py;
|
|
12617
|
+
const sq = dx * dx + dy * dy;
|
|
12618
|
+
if (sq < bestSq || sq === bestSq && i > best) {
|
|
12619
|
+
bestSq = sq;
|
|
12620
|
+
best = i;
|
|
12621
|
+
}
|
|
12622
|
+
}
|
|
12299
12623
|
}
|
|
12300
12624
|
}
|
|
12301
12625
|
return best;
|
|
@@ -13269,8 +13593,8 @@ function resolveValue2(ref, data) {
|
|
|
13269
13593
|
function valueField2(ref) {
|
|
13270
13594
|
return typeof ref === "number" ? null : ref.field;
|
|
13271
13595
|
}
|
|
13272
|
-
function titleText(
|
|
13273
|
-
return resolveTitle2(
|
|
13596
|
+
function titleText(title2) {
|
|
13597
|
+
return resolveTitle2(title2).text;
|
|
13274
13598
|
}
|
|
13275
13599
|
function withAlpha3(color, alpha) {
|
|
13276
13600
|
const rgba2 = parseColor(color);
|
|
@@ -13454,9 +13778,9 @@ function drawGauge(surface, spec, tokens, size, context) {
|
|
|
13454
13778
|
function clamp9(value, min, max) {
|
|
13455
13779
|
return Math.max(min, Math.min(max, value));
|
|
13456
13780
|
}
|
|
13457
|
-
function titleText2(
|
|
13458
|
-
if (typeof
|
|
13459
|
-
if (
|
|
13781
|
+
function titleText2(title2) {
|
|
13782
|
+
if (typeof title2 === "string") return title2;
|
|
13783
|
+
if (title2 && typeof title2 === "object") return title2.text;
|
|
13460
13784
|
return void 0;
|
|
13461
13785
|
}
|
|
13462
13786
|
function valueField3(ref) {
|
|
@@ -15285,6 +15609,13 @@ var Tooltip = class {
|
|
|
15285
15609
|
};
|
|
15286
15610
|
|
|
15287
15611
|
// src/interaction/controller.ts
|
|
15612
|
+
function resolveLegendHit(model, px, py) {
|
|
15613
|
+
for (const hit of model.legendHits ?? []) {
|
|
15614
|
+
const r = hit.rect;
|
|
15615
|
+
if (px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height) return hit;
|
|
15616
|
+
}
|
|
15617
|
+
return null;
|
|
15618
|
+
}
|
|
15288
15619
|
var InteractionController = class {
|
|
15289
15620
|
constructor(surface, tokens) {
|
|
15290
15621
|
__publicField(this, "surface", surface);
|
|
@@ -15313,9 +15644,17 @@ var InteractionController = class {
|
|
|
15313
15644
|
__publicField(this, "onClick", (e) => {
|
|
15314
15645
|
const cfg = this.selectCfg;
|
|
15315
15646
|
const model = this.model;
|
|
15316
|
-
if (!cfg || !model
|
|
15647
|
+
if (!cfg || !model) return;
|
|
15317
15648
|
const rect = this.surface.root.getBoundingClientRect();
|
|
15318
|
-
|
|
15649
|
+
const px = e.clientX - rect.left;
|
|
15650
|
+
const py = e.clientY - rect.top;
|
|
15651
|
+
const onLegendToggle = cfg.onLegendToggle;
|
|
15652
|
+
const legendHit = onLegendToggle ? resolveLegendHit(model, px, py) : null;
|
|
15653
|
+
if (legendHit && onLegendToggle) {
|
|
15654
|
+
onLegendToggle(legendHit, e);
|
|
15655
|
+
return;
|
|
15656
|
+
}
|
|
15657
|
+
if (model.pick && cfg.onPick) cfg.onPick(model.pick(px, py));
|
|
15319
15658
|
});
|
|
15320
15659
|
__publicField(this, "flush", () => {
|
|
15321
15660
|
this.raf = 0;
|
|
@@ -15343,7 +15682,7 @@ var InteractionController = class {
|
|
|
15343
15682
|
this.tokens = tokens;
|
|
15344
15683
|
this.tooltip.setTokens(tokens);
|
|
15345
15684
|
this.activeKey = null;
|
|
15346
|
-
this.pickable = Boolean(model?.pick);
|
|
15685
|
+
this.pickable = Boolean(model?.pick || model?.legendHits?.length);
|
|
15347
15686
|
if (!model) this.tooltip.hide();
|
|
15348
15687
|
}
|
|
15349
15688
|
/** Wire click/tap selection for this chart (or `null` to disable it). */
|
|
@@ -15611,6 +15950,14 @@ function buildRenderReport(input) {
|
|
|
15611
15950
|
const colorCount = model ? new Set(seriesColors).size || 1 : seriesCount;
|
|
15612
15951
|
const markCount = model ? model.series.reduce((n, s) => n + s.rows.length, 0) : data.length;
|
|
15613
15952
|
const plot = model?.plot;
|
|
15953
|
+
if (type === "scatter" && model?.markStats && model.markStats.drawn < model.markStats.original) {
|
|
15954
|
+
add({
|
|
15955
|
+
code: "scatter-quantized",
|
|
15956
|
+
severity: "info",
|
|
15957
|
+
message: `Dense scatter rendering was quantized from ${model.markStats.original} to ${model.markStats.drawn} drawn points to keep interaction smooth.`,
|
|
15958
|
+
details: { drawn: model.markStats.drawn, original: model.markStats.original }
|
|
15959
|
+
});
|
|
15960
|
+
}
|
|
15614
15961
|
const hasLiteralValue = VALUE_CHART_TYPES.has(type) && typeof spec.value === "number";
|
|
15615
15962
|
if (data.length === 0 && !hasLiteralValue) {
|
|
15616
15963
|
add({
|
|
@@ -15634,9 +15981,9 @@ function buildRenderReport(input) {
|
|
|
15634
15981
|
}
|
|
15635
15982
|
if (model) {
|
|
15636
15983
|
checkDegenerateAxis(model, add);
|
|
15637
|
-
checkClippedMarks(model, add);
|
|
15638
|
-
checkAxisLabelOverlap(model, tokens, add);
|
|
15639
|
-
checkLegendOverflow(model, add);
|
|
15984
|
+
checkClippedMarks(model, spec, add);
|
|
15985
|
+
checkAxisLabelOverlap(model, spec, tokens, add);
|
|
15986
|
+
checkLegendOverflow(model, spec, add);
|
|
15640
15987
|
}
|
|
15641
15988
|
if (bg) {
|
|
15642
15989
|
const specColor = spec.color;
|
|
@@ -15675,7 +16022,8 @@ function buildRenderReport(input) {
|
|
|
15675
16022
|
code: "too-many-colors",
|
|
15676
16023
|
severity: "info",
|
|
15677
16024
|
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 }
|
|
16025
|
+
details: { colorCount, max: MAX_DISTINGUISHABLE_SERIES },
|
|
16026
|
+
...tooManyColorsRepair(spec)
|
|
15679
16027
|
});
|
|
15680
16028
|
}
|
|
15681
16029
|
diagnostics.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
|
@@ -15683,6 +16031,11 @@ function buildRenderReport(input) {
|
|
|
15683
16031
|
const summary = summarize({ ...spec, data }) || void 0;
|
|
15684
16032
|
return { type, size, plot, markCount, seriesCount, colorCount, ok, diagnostics, summary };
|
|
15685
16033
|
}
|
|
16034
|
+
function repairReport(spec, report) {
|
|
16035
|
+
const applied = report.diagnostics.flatMap((d) => d.fix ?? []);
|
|
16036
|
+
if (applied.length === 0) return { spec, applied: [] };
|
|
16037
|
+
return { spec: applyPatch(spec, applied), applied };
|
|
16038
|
+
}
|
|
15686
16039
|
function seriesCountOf(spec, data) {
|
|
15687
16040
|
const enc = spec.encoding;
|
|
15688
16041
|
const field = enc?.series?.field ?? enc?.color?.field;
|
|
@@ -15714,7 +16067,7 @@ function checkDegenerateAxis(model, add) {
|
|
|
15714
16067
|
});
|
|
15715
16068
|
}
|
|
15716
16069
|
}
|
|
15717
|
-
function checkClippedMarks(model, add) {
|
|
16070
|
+
function checkClippedMarks(model, spec, add) {
|
|
15718
16071
|
const field = model.y.field;
|
|
15719
16072
|
if (!field) return;
|
|
15720
16073
|
const read = accessor(field);
|
|
@@ -15740,11 +16093,14 @@ function checkClippedMarks(model, add) {
|
|
|
15740
16093
|
severity: "warning",
|
|
15741
16094
|
axis: "y",
|
|
15742
16095
|
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) }
|
|
16096
|
+
details: { dataMin: round(lo), dataMax: round(hi) },
|
|
16097
|
+
...manualYDomainFix(spec) ?? {
|
|
16098
|
+
hint: "Drop the manual y scale domain or set a y scale domain/zero option that includes every data value."
|
|
16099
|
+
}
|
|
15744
16100
|
});
|
|
15745
16101
|
}
|
|
15746
16102
|
}
|
|
15747
|
-
function checkAxisLabelOverlap(model, tokens, add) {
|
|
16103
|
+
function checkAxisLabelOverlap(model, spec, tokens, add) {
|
|
15748
16104
|
const ticks2 = model.xTicks;
|
|
15749
16105
|
if (ticks2.length < 2) return;
|
|
15750
16106
|
if (model.frame.xLabelAngle > 0) return;
|
|
@@ -15764,11 +16120,14 @@ function checkAxisLabelOverlap(model, tokens, add) {
|
|
|
15764
16120
|
severity: "warning",
|
|
15765
16121
|
axis: "x",
|
|
15766
16122
|
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 }
|
|
16123
|
+
details: { collisions, ticks: ticks2.length },
|
|
16124
|
+
...xLabelAngleFix(spec) ?? {
|
|
16125
|
+
hint: "Rotate x-axis labels, facet the chart, widen it, or aggregate the long tail."
|
|
16126
|
+
}
|
|
15768
16127
|
});
|
|
15769
16128
|
}
|
|
15770
16129
|
}
|
|
15771
|
-
function checkLegendOverflow(model, add) {
|
|
16130
|
+
function checkLegendOverflow(model, spec, add) {
|
|
15772
16131
|
const shown = model.frame.legendItems?.length;
|
|
15773
16132
|
if (shown === void 0) return;
|
|
15774
16133
|
const total = model.series.length;
|
|
@@ -15777,10 +16136,52 @@ function checkLegendOverflow(model, add) {
|
|
|
15777
16136
|
code: "legend-overflow",
|
|
15778
16137
|
severity: "warning",
|
|
15779
16138
|
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 }
|
|
16139
|
+
details: { shown, total },
|
|
16140
|
+
...legendPositionFix(spec) ?? {
|
|
16141
|
+
hint: "Move the legend to top/bottom, increase chart height, filter series, or facet the chart."
|
|
16142
|
+
}
|
|
15781
16143
|
});
|
|
15782
16144
|
}
|
|
15783
16145
|
}
|
|
16146
|
+
function isRecord(v) {
|
|
16147
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
16148
|
+
}
|
|
16149
|
+
function xLabelAngleFix(spec) {
|
|
16150
|
+
const axes = spec.axes;
|
|
16151
|
+
if (!isRecord(axes)) return void 0;
|
|
16152
|
+
const x = axes.x;
|
|
16153
|
+
if (x === void 0) return { fix: [{ op: "add", path: "/axes/x", value: { labelAngle: 45 } }] };
|
|
16154
|
+
if (!isRecord(x)) return void 0;
|
|
16155
|
+
const op = Object.prototype.hasOwnProperty.call(x, "labelAngle") ? "replace" : "add";
|
|
16156
|
+
return { fix: [{ op, path: "/axes/x/labelAngle", value: 45 }] };
|
|
16157
|
+
}
|
|
16158
|
+
function legendPositionFix(spec) {
|
|
16159
|
+
const legend = spec.legend;
|
|
16160
|
+
if (legend === false) return void 0;
|
|
16161
|
+
if (legend === void 0 || legend === true) {
|
|
16162
|
+
return { fix: [{ op: legend === void 0 ? "add" : "replace", path: "/legend", value: { position: "bottom" } }] };
|
|
16163
|
+
}
|
|
16164
|
+
if (!isRecord(legend)) return void 0;
|
|
16165
|
+
const current = legend.position;
|
|
16166
|
+
if (current === "bottom" || current === "top") return void 0;
|
|
16167
|
+
const op = Object.prototype.hasOwnProperty.call(legend, "position") ? "replace" : "add";
|
|
16168
|
+
return { fix: [{ op, path: "/legend/position", value: "bottom" }] };
|
|
16169
|
+
}
|
|
16170
|
+
function tooManyColorsRepair(spec) {
|
|
16171
|
+
const palette = spec.palette;
|
|
16172
|
+
const hint = "Switch to the colorblind palette, facet the chart, filter series, or group the long tail.";
|
|
16173
|
+
if (Array.isArray(palette) || palette === "colorblind") return { hint };
|
|
16174
|
+
return { fix: [{ op: palette === void 0 ? "add" : "replace", path: "/palette", value: "colorblind" }], hint };
|
|
16175
|
+
}
|
|
16176
|
+
function manualYDomainFix(spec) {
|
|
16177
|
+
const enc = spec.encoding;
|
|
16178
|
+
if (!isRecord(enc)) return void 0;
|
|
16179
|
+
const y = enc.y;
|
|
16180
|
+
if (!isRecord(y)) return void 0;
|
|
16181
|
+
const scale = y.scale;
|
|
16182
|
+
if (!isRecord(scale) || !Object.prototype.hasOwnProperty.call(scale, "domain")) return void 0;
|
|
16183
|
+
return { fix: [{ op: "remove", path: "/encoding/y/scale/domain" }] };
|
|
16184
|
+
}
|
|
15784
16185
|
|
|
15785
16186
|
// src/runtime/facet.ts
|
|
15786
16187
|
var FACETABLE_TYPES2 = /* @__PURE__ */ new Set(["line", "area", "bar", "scatter"]);
|
|
@@ -15816,9 +16217,9 @@ function autoColumns(n) {
|
|
|
15816
16217
|
function legendSymbol(type) {
|
|
15817
16218
|
return type === "scatter" ? "circle" : type === "line" ? "line" : "square";
|
|
15818
16219
|
}
|
|
15819
|
-
function titleInput3(
|
|
15820
|
-
if (!
|
|
15821
|
-
return typeof
|
|
16220
|
+
function titleInput3(title2) {
|
|
16221
|
+
if (!title2) return void 0;
|
|
16222
|
+
return typeof title2 === "string" ? { text: title2 } : title2;
|
|
15822
16223
|
}
|
|
15823
16224
|
function panelSpec(spec, data, value) {
|
|
15824
16225
|
const base = spec;
|
|
@@ -15989,6 +16390,60 @@ var CANVAS_MARK_TYPES = /* @__PURE__ */ new Set([
|
|
|
15989
16390
|
"choropleth"
|
|
15990
16391
|
]);
|
|
15991
16392
|
var EMPTY_DATA = [];
|
|
16393
|
+
function legendConfigFor(model) {
|
|
16394
|
+
const legend = model.spec.legend;
|
|
16395
|
+
if (!(typeof legend === "object" && legend.interactive === true)) return null;
|
|
16396
|
+
const field = model.seriesField;
|
|
16397
|
+
if (!field || !model.legendHits?.length) return null;
|
|
16398
|
+
return { param: legend.param ?? field, field };
|
|
16399
|
+
}
|
|
16400
|
+
function visibleLegendKeys(value, field) {
|
|
16401
|
+
if (!value || value.kind !== "set" || value.field !== field) return null;
|
|
16402
|
+
return new Set(value.values.map((v) => toKey(v)));
|
|
16403
|
+
}
|
|
16404
|
+
function applyLegendVisibility(model, store) {
|
|
16405
|
+
const cfg = legendConfigFor(model);
|
|
16406
|
+
if (!cfg) return null;
|
|
16407
|
+
const visible = visibleLegendKeys(store.get(cfg.param), cfg.field);
|
|
16408
|
+
if (!visible) return cfg;
|
|
16409
|
+
model.series = model.series.filter((s) => visible.has(toKey(s.value)));
|
|
16410
|
+
return cfg;
|
|
16411
|
+
}
|
|
16412
|
+
function publishLegendToggle(store, cfg, allHits, hit, isolate) {
|
|
16413
|
+
const all = allHits.map((h) => h.value);
|
|
16414
|
+
const allKeys = all.map((v) => toKey(v));
|
|
16415
|
+
const hitKey = toKey(hit.value);
|
|
16416
|
+
const current = visibleLegendKeys(store.get(cfg.param), cfg.field);
|
|
16417
|
+
const visible = current ? new Set(current) : new Set(allKeys);
|
|
16418
|
+
if (isolate) {
|
|
16419
|
+
if (visible.size === 1 && visible.has(hitKey)) {
|
|
16420
|
+
store.set(cfg.param, null);
|
|
16421
|
+
return;
|
|
16422
|
+
}
|
|
16423
|
+
visible.clear();
|
|
16424
|
+
visible.add(hitKey);
|
|
16425
|
+
} else if (visible.has(hitKey)) {
|
|
16426
|
+
visible.delete(hitKey);
|
|
16427
|
+
} else {
|
|
16428
|
+
visible.add(hitKey);
|
|
16429
|
+
}
|
|
16430
|
+
if (visible.size === 0 || visible.size === allKeys.length) {
|
|
16431
|
+
store.set(cfg.param, null);
|
|
16432
|
+
return;
|
|
16433
|
+
}
|
|
16434
|
+
store.set(cfg.param, {
|
|
16435
|
+
kind: "set",
|
|
16436
|
+
field: cfg.field,
|
|
16437
|
+
values: all.filter((v) => visible.has(toKey(v)))
|
|
16438
|
+
});
|
|
16439
|
+
}
|
|
16440
|
+
function specLegendParam(spec) {
|
|
16441
|
+
const legend = spec.legend;
|
|
16442
|
+
if (!(typeof legend === "object" && legend.interactive === true)) return null;
|
|
16443
|
+
if (legend.param) return legend.param;
|
|
16444
|
+
const enc = spec.encoding;
|
|
16445
|
+
return enc?.series?.field ?? enc?.color?.field ?? null;
|
|
16446
|
+
}
|
|
15992
16447
|
function resolveContainer(target) {
|
|
15993
16448
|
if (typeof target === "string") {
|
|
15994
16449
|
const el = document.querySelector(target);
|
|
@@ -16084,6 +16539,7 @@ function render(target, spec, options) {
|
|
|
16084
16539
|
let interactionModel = null;
|
|
16085
16540
|
let reportModel;
|
|
16086
16541
|
let facetRpt;
|
|
16542
|
+
let legendSelect = null;
|
|
16087
16543
|
const type = currentSpec.type;
|
|
16088
16544
|
const facetLayout = isFaceted(effectiveSpec) ? buildFacetModels(effectiveSpec, tokens, size) : null;
|
|
16089
16545
|
if (facetLayout) {
|
|
@@ -16092,6 +16548,8 @@ function render(target, spec, options) {
|
|
|
16092
16548
|
} else if (CARTESIAN_TYPES.has(type)) {
|
|
16093
16549
|
const renderer = cartesianRenderers[type];
|
|
16094
16550
|
const model = buildCartesianModel(effectiveSpec, tokens, size);
|
|
16551
|
+
const legendCfg = applyLegendVisibility(model, store);
|
|
16552
|
+
if (legendCfg && model.legendHits?.length) legendSelect = { cfg: legendCfg, hits: model.legendHits };
|
|
16095
16553
|
model.emphasis = emphasis;
|
|
16096
16554
|
reportModel = model;
|
|
16097
16555
|
if (!anim.enabled) {
|
|
@@ -16140,9 +16598,14 @@ function render(target, spec, options) {
|
|
|
16140
16598
|
}
|
|
16141
16599
|
if (!interaction) interaction = new InteractionController(surface, tokens);
|
|
16142
16600
|
interaction.setModel(interactionModel, tokens);
|
|
16143
|
-
if (ownParam && interactionModel?.pick) {
|
|
16144
|
-
const cfg = { store, param: ownParam.name, def: ownParam.select };
|
|
16145
|
-
interaction.setSelect({
|
|
16601
|
+
if (ownParam && interactionModel?.pick || legendSelect && interactionModel?.legendHits?.length) {
|
|
16602
|
+
const cfg = ownParam ? { store, param: ownParam.name, def: ownParam.select } : null;
|
|
16603
|
+
interaction.setSelect({
|
|
16604
|
+
onPick: cfg && interactionModel?.pick ? (value) => applyPick(cfg, value) : void 0,
|
|
16605
|
+
onLegendToggle: legendSelect ? (hit, event) => {
|
|
16606
|
+
publishLegendToggle(store, legendSelect.cfg, legendSelect.hits, hit, event.shiftKey || event.altKey);
|
|
16607
|
+
} : void 0
|
|
16608
|
+
});
|
|
16146
16609
|
} else {
|
|
16147
16610
|
interaction.setSelect(null);
|
|
16148
16611
|
}
|
|
@@ -16199,7 +16662,7 @@ function render(target, spec, options) {
|
|
|
16199
16662
|
const unsubscribe = store.subscribe((name, value) => {
|
|
16200
16663
|
for (const listener of [...hostListeners]) listener(name, value);
|
|
16201
16664
|
if (destroyed) return;
|
|
16202
|
-
if (dependentParams(currentSpec.highlight, currentSpec.filter).has(name)) {
|
|
16665
|
+
if (dependentParams(currentSpec.highlight, currentSpec.filter).has(name) || specLegendParam(currentSpec) === name) {
|
|
16203
16666
|
draw(false);
|
|
16204
16667
|
}
|
|
16205
16668
|
});
|
|
@@ -16380,6 +16843,14 @@ function isSlicerType(type) {
|
|
|
16380
16843
|
function fieldOf(channel) {
|
|
16381
16844
|
return channel && typeof channel.field === "string" ? channel.field : void 0;
|
|
16382
16845
|
}
|
|
16846
|
+
function legendSource(spec) {
|
|
16847
|
+
const legend = spec.legend;
|
|
16848
|
+
if (!(typeof legend === "object" && legend.interactive === true)) return null;
|
|
16849
|
+
const enc = spec.encoding;
|
|
16850
|
+
const field = fieldOf(enc?.series) ?? fieldOf(enc?.color);
|
|
16851
|
+
if (!field) return null;
|
|
16852
|
+
return { param: legend.param ?? field, field };
|
|
16853
|
+
}
|
|
16383
16854
|
function specFields(spec) {
|
|
16384
16855
|
const out = /* @__PURE__ */ new Set();
|
|
16385
16856
|
if (isSlicerType(spec.type)) {
|
|
@@ -16468,9 +16939,14 @@ function wireViews(views, interactions) {
|
|
|
16468
16939
|
});
|
|
16469
16940
|
} else if (PICKABLE.has(type)) {
|
|
16470
16941
|
const fields = keyFields(v.spec);
|
|
16471
|
-
if (fields.length
|
|
16472
|
-
|
|
16473
|
-
|
|
16942
|
+
if (fields.length > 0) {
|
|
16943
|
+
const param = autoParamName(v);
|
|
16944
|
+
sources.push({ view: i, param, fields, as: "highlight", slicer: false });
|
|
16945
|
+
}
|
|
16946
|
+
const legend = legendSource(v.spec);
|
|
16947
|
+
if (legend) {
|
|
16948
|
+
sources.push({ view: i, param: legend.param, fields: [legend.field], as: "filter", slicer: true });
|
|
16949
|
+
}
|
|
16474
16950
|
}
|
|
16475
16951
|
});
|
|
16476
16952
|
const explicit = Array.isArray(interactions);
|
|
@@ -16536,6 +17012,14 @@ var DEFAULT_BREAKPOINTS = [
|
|
|
16536
17012
|
{ maxWidth: 960, cols: 6 }
|
|
16537
17013
|
];
|
|
16538
17014
|
var STRIP_SLICERS = /* @__PURE__ */ new Set(["dropdown", "search", "dateRange", "range"]);
|
|
17015
|
+
function stripCellHeight(view, rowHeight) {
|
|
17016
|
+
const titled = !!(view.title || view.subtitle);
|
|
17017
|
+
const titlePad = titled ? 30 : 0;
|
|
17018
|
+
const base = Math.max(96, rowHeight + 8);
|
|
17019
|
+
if (view.spec.type === "dateRange") return Math.max(base, 150 + titlePad);
|
|
17020
|
+
if (view.spec.type === "range") return Math.max(base, 132 + titlePad);
|
|
17021
|
+
return Math.max(base, 96 + titlePad);
|
|
17022
|
+
}
|
|
16539
17023
|
var clamp10 = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
16540
17024
|
function defaultSpan(spec, cols) {
|
|
16541
17025
|
const t = spec.type;
|
|
@@ -16799,14 +17283,14 @@ function renderDashboard(target, spec, options) {
|
|
|
16799
17283
|
gap: "2px"
|
|
16800
17284
|
});
|
|
16801
17285
|
if (view.title) {
|
|
16802
|
-
const
|
|
16803
|
-
|
|
16804
|
-
Object.assign(
|
|
17286
|
+
const title2 = document.createElement("div");
|
|
17287
|
+
title2.textContent = view.title;
|
|
17288
|
+
Object.assign(title2.style, {
|
|
16805
17289
|
font: `${tokens.font.weight.bold} ${tokens.font.size.base}px ${tokens.font.family}`,
|
|
16806
17290
|
color: tokens.color.text,
|
|
16807
17291
|
letterSpacing: "-0.01em"
|
|
16808
17292
|
});
|
|
16809
|
-
header.appendChild(
|
|
17293
|
+
header.appendChild(title2);
|
|
16810
17294
|
}
|
|
16811
17295
|
if (view.subtitle) {
|
|
16812
17296
|
const subtitle = document.createElement("div");
|
|
@@ -16838,6 +17322,7 @@ function renderDashboard(target, spec, options) {
|
|
|
16838
17322
|
Object.assign(strip.style, {
|
|
16839
17323
|
display: "flex",
|
|
16840
17324
|
flexWrap: "wrap",
|
|
17325
|
+
alignItems: "flex-start",
|
|
16841
17326
|
gap: `${layout2.gap}px`
|
|
16842
17327
|
});
|
|
16843
17328
|
for (const view of stripViews) {
|
|
@@ -16845,7 +17330,7 @@ function renderDashboard(target, spec, options) {
|
|
|
16845
17330
|
Object.assign(cell.style, {
|
|
16846
17331
|
flex: "1 1 220px",
|
|
16847
17332
|
minWidth: "200px",
|
|
16848
|
-
height: `${
|
|
17333
|
+
height: `${stripCellHeight(view, layout2.rowHeight)}px`
|
|
16849
17334
|
});
|
|
16850
17335
|
strip.appendChild(cell);
|
|
16851
17336
|
deferredMounts.push({ el: host, view, mounted: false });
|
|
@@ -16885,10 +17370,10 @@ function renderDashboard(target, spec, options) {
|
|
|
16885
17370
|
const titles = document.createElement("span");
|
|
16886
17371
|
Object.assign(titles.style, { display: "flex", flexDirection: "column", gap: "2px" });
|
|
16887
17372
|
if (plan.section?.title) {
|
|
16888
|
-
const
|
|
16889
|
-
|
|
16890
|
-
Object.assign(
|
|
16891
|
-
titles.appendChild(
|
|
17373
|
+
const title2 = document.createElement("span");
|
|
17374
|
+
title2.textContent = plan.section.title;
|
|
17375
|
+
Object.assign(title2.style, { fontWeight: String(tokens.font.weight.bold), fontSize: `${tokens.font.size.large}px` });
|
|
17376
|
+
titles.appendChild(title2);
|
|
16892
17377
|
}
|
|
16893
17378
|
if (plan.section?.subtitle) {
|
|
16894
17379
|
const subtitle = document.createElement("span");
|
|
@@ -17231,6 +17716,6 @@ function renderToContext(target, spec) {
|
|
|
17231
17716
|
// src/index.ts
|
|
17232
17717
|
var VERSION = "0.1.0";
|
|
17233
17718
|
|
|
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 };
|
|
17719
|
+
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
17720
|
//# sourceMappingURL=index.js.map
|
|
17236
17721
|
//# sourceMappingURL=index.js.map
|