graphein 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +80 -2
- package/dist/index.js +2169 -1536
- 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;
|
|
@@ -5748,16 +5284,693 @@ function validateDashboard(spec) {
|
|
|
5748
5284
|
}
|
|
5749
5285
|
}
|
|
5750
5286
|
}
|
|
5751
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
5287
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
5288
|
+
}
|
|
5289
|
+
function assertValidSpec(spec) {
|
|
5290
|
+
const { valid, errors } = validateSpec(spec);
|
|
5291
|
+
if (!valid) {
|
|
5292
|
+
const detail = errors.map((e) => ` - ${e.path || "(root)"}: ${e.message}`).join("\n");
|
|
5293
|
+
throw new Error(`Invalid Graphein chart spec:
|
|
5294
|
+
${detail}`);
|
|
5295
|
+
}
|
|
5296
|
+
return spec;
|
|
5297
|
+
}
|
|
5298
|
+
|
|
5299
|
+
// src/analyze/recommend.ts
|
|
5300
|
+
function profileColumns(data) {
|
|
5301
|
+
const fields = /* @__PURE__ */ new Set();
|
|
5302
|
+
for (const row of data) {
|
|
5303
|
+
for (const field of Object.keys(row)) fields.add(field);
|
|
5304
|
+
}
|
|
5305
|
+
return [...fields].map((field) => ({
|
|
5306
|
+
field,
|
|
5307
|
+
type: inferType(data, field),
|
|
5308
|
+
cardinality: uniqueValues(data, field).length
|
|
5309
|
+
}));
|
|
5310
|
+
}
|
|
5311
|
+
function title(text) {
|
|
5312
|
+
return text.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w/g, (m) => m.toUpperCase());
|
|
5313
|
+
}
|
|
5314
|
+
function fieldDef2(profile) {
|
|
5315
|
+
return { field: profile.field, type: profile.type };
|
|
5316
|
+
}
|
|
5317
|
+
function boost(score, family, intent) {
|
|
5318
|
+
if (!intent) return score;
|
|
5319
|
+
const matches = family === intent || intent === "composition" && family === "comparison" || intent === "comparison" && family === "density";
|
|
5320
|
+
return Math.min(1, score + (matches ? 0.12 : 0));
|
|
5321
|
+
}
|
|
5322
|
+
function pushValid(candidates, candidate, intent) {
|
|
5323
|
+
const score = Number(boost(candidate.score, candidate.family, intent).toFixed(3));
|
|
5324
|
+
const spec = candidate.spec;
|
|
5325
|
+
if (!validateSpec(spec).valid) return;
|
|
5326
|
+
candidates.push({ ...candidate, score });
|
|
5327
|
+
}
|
|
5328
|
+
function firstLowCardNominal(profiles, exclude = /* @__PURE__ */ new Set()) {
|
|
5329
|
+
return profiles.find((p) => p.type === "nominal" && p.cardinality <= 12 && !exclude.has(p.field));
|
|
5330
|
+
}
|
|
5331
|
+
function recommendChart(data, opts = {}) {
|
|
5332
|
+
if (!Array.isArray(data) || data.length === 0) return [];
|
|
5333
|
+
const profiles = profileColumns(data);
|
|
5334
|
+
const quantitative = profiles.filter((p) => p.type === "quantitative");
|
|
5335
|
+
const ordered = profiles.filter((p) => p.type === "temporal" || p.type === "ordinal");
|
|
5336
|
+
const nominal = profiles.filter((p) => p.type === "nominal");
|
|
5337
|
+
const lowNominal = nominal.filter((p) => p.cardinality <= 12);
|
|
5338
|
+
const manyNominal = nominal.filter((p) => p.cardinality > 12);
|
|
5339
|
+
const candidates = [];
|
|
5340
|
+
const measure = quantitative[0];
|
|
5341
|
+
if (ordered.length > 0 && measure) {
|
|
5342
|
+
const x = ordered[0];
|
|
5343
|
+
const encoding = { x: fieldDef2(x), y: fieldDef2(measure) };
|
|
5344
|
+
const series = firstLowCardNominal(profiles, /* @__PURE__ */ new Set([x.field, measure.field]));
|
|
5345
|
+
if (series) encoding.series = fieldDef2(series);
|
|
5346
|
+
pushValid(
|
|
5347
|
+
candidates,
|
|
5348
|
+
{
|
|
5349
|
+
spec: {
|
|
5350
|
+
type: "line",
|
|
5351
|
+
data,
|
|
5352
|
+
encoding,
|
|
5353
|
+
title: `${title(measure.field)} over ${title(x.field)}`
|
|
5354
|
+
},
|
|
5355
|
+
score: 0.92,
|
|
5356
|
+
family: "trend",
|
|
5357
|
+
rationale: `${title(x.field)} is ${x.type}, so a line chart shows how ${title(measure.field)} changes over ordered rows${series ? `, split by low-cardinality ${title(series.field)}` : ""}.`
|
|
5358
|
+
},
|
|
5359
|
+
opts.intent
|
|
5360
|
+
);
|
|
5361
|
+
}
|
|
5362
|
+
if (measure) {
|
|
5363
|
+
const category = lowNominal[0];
|
|
5364
|
+
if (category) {
|
|
5365
|
+
pushValid(
|
|
5366
|
+
candidates,
|
|
5367
|
+
{
|
|
5368
|
+
spec: {
|
|
5369
|
+
type: "bar",
|
|
5370
|
+
data,
|
|
5371
|
+
encoding: { x: fieldDef2(category), y: fieldDef2(measure) },
|
|
5372
|
+
title: `${title(measure.field)} by ${title(category.field)}`
|
|
5373
|
+
},
|
|
5374
|
+
score: 0.84,
|
|
5375
|
+
family: "comparison",
|
|
5376
|
+
rationale: `${title(category.field)} has ${category.cardinality} categories, making bars a good comparison of ${title(measure.field)}.`
|
|
5377
|
+
},
|
|
5378
|
+
opts.intent
|
|
5379
|
+
);
|
|
5380
|
+
if (category.cardinality <= 7) {
|
|
5381
|
+
pushValid(
|
|
5382
|
+
candidates,
|
|
5383
|
+
{
|
|
5384
|
+
spec: {
|
|
5385
|
+
type: "pie",
|
|
5386
|
+
data,
|
|
5387
|
+
encoding: { theta: fieldDef2(measure), color: fieldDef2(category) },
|
|
5388
|
+
title: `${title(measure.field)} share by ${title(category.field)}`
|
|
5389
|
+
},
|
|
5390
|
+
score: 0.58,
|
|
5391
|
+
family: "composition",
|
|
5392
|
+
rationale: `${title(category.field)} has ${category.cardinality} slices, so a pie can show part-to-whole share for ${title(measure.field)}.`
|
|
5393
|
+
},
|
|
5394
|
+
opts.intent
|
|
5395
|
+
);
|
|
5396
|
+
}
|
|
5397
|
+
} else if (manyNominal.length > 0) {
|
|
5398
|
+
const wideCategory = manyNominal[0];
|
|
5399
|
+
pushValid(
|
|
5400
|
+
candidates,
|
|
5401
|
+
{
|
|
5402
|
+
spec: {
|
|
5403
|
+
type: "bar",
|
|
5404
|
+
data,
|
|
5405
|
+
encoding: { x: fieldDef2(wideCategory), y: fieldDef2(measure) },
|
|
5406
|
+
title: `${title(measure.field)} by ${title(wideCategory.field)}`
|
|
5407
|
+
},
|
|
5408
|
+
score: 0.62,
|
|
5409
|
+
family: "comparison",
|
|
5410
|
+
rationale: `${title(wideCategory.field)} has ${wideCategory.cardinality} categories; use this bar chart with top-N filtering or grouping to keep labels readable.`
|
|
5411
|
+
},
|
|
5412
|
+
opts.intent
|
|
5413
|
+
);
|
|
5414
|
+
}
|
|
5415
|
+
}
|
|
5416
|
+
if (quantitative.length >= 2) {
|
|
5417
|
+
const [x, y] = quantitative;
|
|
5418
|
+
const encoding = { x: fieldDef2(x), y: fieldDef2(y) };
|
|
5419
|
+
const size = quantitative[2];
|
|
5420
|
+
const color = firstLowCardNominal(profiles, /* @__PURE__ */ new Set([x.field, y.field]));
|
|
5421
|
+
if (size) encoding.size = fieldDef2(size);
|
|
5422
|
+
if (color) encoding.color = fieldDef2(color);
|
|
5423
|
+
pushValid(
|
|
5424
|
+
candidates,
|
|
5425
|
+
{
|
|
5426
|
+
spec: {
|
|
5427
|
+
type: "scatter",
|
|
5428
|
+
data,
|
|
5429
|
+
encoding,
|
|
5430
|
+
title: `${title(y.field)} vs ${title(x.field)}`
|
|
5431
|
+
},
|
|
5432
|
+
score: 0.86,
|
|
5433
|
+
family: "relationship",
|
|
5434
|
+
rationale: `${title(x.field)} and ${title(y.field)} are quantitative, so scatter reveals their relationship${size ? ` with ${title(size.field)} as bubble size` : ""}${color ? ` and ${title(color.field)} as color` : ""}.`
|
|
5435
|
+
},
|
|
5436
|
+
opts.intent
|
|
5437
|
+
);
|
|
5438
|
+
}
|
|
5439
|
+
if (quantitative.length === 1) {
|
|
5440
|
+
const q = quantitative[0];
|
|
5441
|
+
pushValid(
|
|
5442
|
+
candidates,
|
|
5443
|
+
{
|
|
5444
|
+
spec: {
|
|
5445
|
+
type: "histogram",
|
|
5446
|
+
data,
|
|
5447
|
+
encoding: { x: fieldDef2(q) },
|
|
5448
|
+
title: `${title(q.field)} distribution`
|
|
5449
|
+
},
|
|
5450
|
+
score: 0.78,
|
|
5451
|
+
family: "distribution",
|
|
5452
|
+
rationale: `${title(q.field)} is the only quantitative field, so a histogram shows its distribution.`
|
|
5453
|
+
},
|
|
5454
|
+
opts.intent
|
|
5455
|
+
);
|
|
5456
|
+
pushValid(
|
|
5457
|
+
candidates,
|
|
5458
|
+
{
|
|
5459
|
+
spec: {
|
|
5460
|
+
type: "kpi",
|
|
5461
|
+
data,
|
|
5462
|
+
value: { field: q.field, aggregate: "sum" },
|
|
5463
|
+
label: `Total ${title(q.field)}`,
|
|
5464
|
+
title: `Total ${title(q.field)}`
|
|
5465
|
+
},
|
|
5466
|
+
score: 0.62,
|
|
5467
|
+
family: "value",
|
|
5468
|
+
rationale: `${title(q.field)} is the only quantitative field, so a KPI summarizes it as a total.`
|
|
5469
|
+
},
|
|
5470
|
+
opts.intent
|
|
5471
|
+
);
|
|
5472
|
+
}
|
|
5473
|
+
if (lowNominal.length >= 2 && measure) {
|
|
5474
|
+
const [x, y] = lowNominal;
|
|
5475
|
+
pushValid(
|
|
5476
|
+
candidates,
|
|
5477
|
+
{
|
|
5478
|
+
spec: {
|
|
5479
|
+
type: "heatmap",
|
|
5480
|
+
data,
|
|
5481
|
+
encoding: { x: fieldDef2(x), y: fieldDef2(y), color: fieldDef2(measure) },
|
|
5482
|
+
title: `${title(measure.field)} by ${title(x.field)} and ${title(y.field)}`
|
|
5483
|
+
},
|
|
5484
|
+
score: 0.76,
|
|
5485
|
+
family: "density",
|
|
5486
|
+
rationale: `${title(x.field)} and ${title(y.field)} are low-cardinality categories, so a heatmap shows ${title(measure.field)} across their combinations.`
|
|
5487
|
+
},
|
|
5488
|
+
opts.intent
|
|
5489
|
+
);
|
|
5490
|
+
pushValid(
|
|
5491
|
+
candidates,
|
|
5492
|
+
{
|
|
5493
|
+
spec: {
|
|
5494
|
+
type: "matrix",
|
|
5495
|
+
data,
|
|
5496
|
+
rows: [y.field],
|
|
5497
|
+
columns: [x.field],
|
|
5498
|
+
values: [{ field: measure.field, op: "sum" }],
|
|
5499
|
+
title: `${title(measure.field)} matrix`
|
|
5500
|
+
},
|
|
5501
|
+
score: 0.68,
|
|
5502
|
+
family: "density",
|
|
5503
|
+
rationale: `A matrix summarizes ${title(measure.field)} by ${title(y.field)} rows and ${title(x.field)} columns.`
|
|
5504
|
+
},
|
|
5505
|
+
opts.intent
|
|
5506
|
+
);
|
|
5507
|
+
}
|
|
5508
|
+
return candidates.sort((a, b) => b.score - a.score).slice(0, opts.maxResults ?? 5).map(({ spec, score, rationale }) => ({ spec, score, rationale }));
|
|
5509
|
+
}
|
|
5510
|
+
|
|
5511
|
+
// src/render/env.ts
|
|
5512
|
+
var isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
5513
|
+
function getDevicePixelRatio() {
|
|
5514
|
+
const dpr = globalThis.devicePixelRatio;
|
|
5515
|
+
return typeof dpr === "number" && dpr > 0 ? dpr : 1;
|
|
5516
|
+
}
|
|
5517
|
+
|
|
5518
|
+
// src/render/sizing.ts
|
|
5519
|
+
function computeBackingSize(width, height, dpr) {
|
|
5520
|
+
const d = dpr > 0 ? dpr : 1;
|
|
5521
|
+
const cssWidth = Math.max(0, width);
|
|
5522
|
+
const cssHeight = Math.max(0, height);
|
|
5523
|
+
return {
|
|
5524
|
+
cssWidth,
|
|
5525
|
+
cssHeight,
|
|
5526
|
+
pixelWidth: Math.max(1, Math.round(cssWidth * d)),
|
|
5527
|
+
pixelHeight: Math.max(1, Math.round(cssHeight * d)),
|
|
5528
|
+
dpr: d
|
|
5529
|
+
};
|
|
5530
|
+
}
|
|
5531
|
+
|
|
5532
|
+
// src/render/dom.ts
|
|
5533
|
+
function setStyle(el, style) {
|
|
5534
|
+
Object.assign(el.style, style);
|
|
5535
|
+
}
|
|
5536
|
+
function createDiv(className, style) {
|
|
5537
|
+
const el = document.createElement("div");
|
|
5538
|
+
if (className) el.className = className;
|
|
5539
|
+
if (style) setStyle(el, style);
|
|
5540
|
+
return el;
|
|
5541
|
+
}
|
|
5542
|
+
var fillParentStyle = {
|
|
5543
|
+
position: "absolute",
|
|
5544
|
+
top: "0",
|
|
5545
|
+
left: "0"
|
|
5546
|
+
};
|
|
5547
|
+
|
|
5548
|
+
// src/render/canvasLayer.ts
|
|
5549
|
+
var CanvasLayer = class {
|
|
5550
|
+
constructor(canvas) {
|
|
5551
|
+
__publicField(this, "canvas");
|
|
5552
|
+
__publicField(this, "ctx");
|
|
5553
|
+
/** Logical (CSS px) width. */
|
|
5554
|
+
__publicField(this, "width", 0);
|
|
5555
|
+
/** Logical (CSS px) height. */
|
|
5556
|
+
__publicField(this, "height", 0);
|
|
5557
|
+
__publicField(this, "dpr", 1);
|
|
5558
|
+
this.canvas = canvas ?? document.createElement("canvas");
|
|
5559
|
+
const ctx = this.canvas.getContext("2d");
|
|
5560
|
+
if (!ctx) {
|
|
5561
|
+
throw new Error("Graphein: 2D canvas context is unavailable in this environment.");
|
|
5562
|
+
}
|
|
5563
|
+
this.ctx = ctx;
|
|
5564
|
+
}
|
|
5565
|
+
/** Resize the layer to a CSS size; updates the backing store + DPR transform. */
|
|
5566
|
+
resize(width, height, dpr = getDevicePixelRatio()) {
|
|
5567
|
+
const b = computeBackingSize(width, height, dpr);
|
|
5568
|
+
this.width = b.cssWidth;
|
|
5569
|
+
this.height = b.cssHeight;
|
|
5570
|
+
this.dpr = b.dpr;
|
|
5571
|
+
if (this.canvas.width !== b.pixelWidth) this.canvas.width = b.pixelWidth;
|
|
5572
|
+
if (this.canvas.height !== b.pixelHeight) this.canvas.height = b.pixelHeight;
|
|
5573
|
+
this.canvas.style.width = `${b.cssWidth}px`;
|
|
5574
|
+
this.canvas.style.height = `${b.cssHeight}px`;
|
|
5575
|
+
this.ctx.setTransform(b.dpr, 0, 0, b.dpr, 0, 0);
|
|
5576
|
+
}
|
|
5577
|
+
/** Clear the entire backing store regardless of the current transform. */
|
|
5578
|
+
clear() {
|
|
5579
|
+
const { ctx, canvas } = this;
|
|
5580
|
+
ctx.save();
|
|
5581
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
5582
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
5583
|
+
ctx.restore();
|
|
5584
|
+
}
|
|
5585
|
+
};
|
|
5586
|
+
|
|
5587
|
+
// src/render/surface.ts
|
|
5588
|
+
var Surface = class {
|
|
5589
|
+
constructor(container) {
|
|
5590
|
+
__publicField(this, "container");
|
|
5591
|
+
__publicField(this, "root");
|
|
5592
|
+
__publicField(this, "marks");
|
|
5593
|
+
__publicField(this, "interaction");
|
|
5594
|
+
__publicField(this, "overlay");
|
|
5595
|
+
/** Visually-hidden container for the screen-reader data-table fallback. */
|
|
5596
|
+
__publicField(this, "a11y");
|
|
5597
|
+
__publicField(this, "width", 0);
|
|
5598
|
+
__publicField(this, "height", 0);
|
|
5599
|
+
__publicField(this, "dpr", 1);
|
|
5600
|
+
/**
|
|
5601
|
+
* When true, overlay text (axis labels, titles, legends, annotation labels) is
|
|
5602
|
+
* painted onto the `marks` canvas instead of the HTML overlay. Set by headless
|
|
5603
|
+
* renderers (`@graphein/node`) where there is no DOM to host crisp text.
|
|
5604
|
+
* Defaults to false (browser).
|
|
5605
|
+
*/
|
|
5606
|
+
__publicField(this, "headless", false);
|
|
5607
|
+
this.container = container;
|
|
5608
|
+
this.root = createDiv("graphein-root", {
|
|
5609
|
+
position: "relative",
|
|
5610
|
+
width: "100%",
|
|
5611
|
+
height: "100%",
|
|
5612
|
+
overflow: "hidden"
|
|
5613
|
+
});
|
|
5614
|
+
const marksCanvas = document.createElement("canvas");
|
|
5615
|
+
marksCanvas.className = "graphein-layer-marks";
|
|
5616
|
+
setStyle(marksCanvas, { ...fillParentStyle });
|
|
5617
|
+
const interactionCanvas = document.createElement("canvas");
|
|
5618
|
+
interactionCanvas.className = "graphein-layer-interaction";
|
|
5619
|
+
setStyle(interactionCanvas, { ...fillParentStyle, pointerEvents: "none" });
|
|
5620
|
+
this.overlay = createDiv("graphein-layer-overlay", {
|
|
5621
|
+
...fillParentStyle,
|
|
5622
|
+
width: "100%",
|
|
5623
|
+
height: "100%",
|
|
5624
|
+
pointerEvents: "none"
|
|
5625
|
+
});
|
|
5626
|
+
this.a11y = createDiv("graphein-a11y", {
|
|
5627
|
+
position: "absolute",
|
|
5628
|
+
width: "1px",
|
|
5629
|
+
height: "1px",
|
|
5630
|
+
overflow: "hidden",
|
|
5631
|
+
clip: "rect(0 0 0 0)",
|
|
5632
|
+
clipPath: "inset(50%)",
|
|
5633
|
+
whiteSpace: "nowrap",
|
|
5634
|
+
border: "0",
|
|
5635
|
+
margin: "-1px",
|
|
5636
|
+
padding: "0"
|
|
5637
|
+
});
|
|
5638
|
+
this.root.appendChild(marksCanvas);
|
|
5639
|
+
this.root.appendChild(interactionCanvas);
|
|
5640
|
+
this.root.appendChild(this.overlay);
|
|
5641
|
+
this.root.appendChild(this.a11y);
|
|
5642
|
+
container.appendChild(this.root);
|
|
5643
|
+
this.marks = new CanvasLayer(marksCanvas);
|
|
5644
|
+
this.interaction = new CanvasLayer(interactionCanvas);
|
|
5645
|
+
}
|
|
5646
|
+
resize(width, height, dpr = getDevicePixelRatio()) {
|
|
5647
|
+
this.width = width;
|
|
5648
|
+
this.height = height;
|
|
5649
|
+
this.dpr = dpr;
|
|
5650
|
+
this.marks.resize(width, height, dpr);
|
|
5651
|
+
this.interaction.resize(width, height, dpr);
|
|
5652
|
+
setStyle(this.overlay, { width: `${width}px`, height: `${height}px` });
|
|
5653
|
+
}
|
|
5654
|
+
/** Clear both canvas layers and empty the HTML overlay. */
|
|
5655
|
+
clear() {
|
|
5656
|
+
this.marks.clear();
|
|
5657
|
+
this.interaction.clear();
|
|
5658
|
+
this.overlay.replaceChildren();
|
|
5659
|
+
}
|
|
5660
|
+
/** Remove all DOM created by this Surface. */
|
|
5661
|
+
destroy() {
|
|
5662
|
+
this.clear();
|
|
5663
|
+
this.root.remove();
|
|
5664
|
+
}
|
|
5665
|
+
};
|
|
5666
|
+
|
|
5667
|
+
// src/render/overlayText.ts
|
|
5668
|
+
function paintCanvasText(ctx, c) {
|
|
5669
|
+
const align = c.align ?? "left";
|
|
5670
|
+
const baseline = c.baseline ?? "top";
|
|
5671
|
+
ctx.save();
|
|
5672
|
+
if (c.opacity != null) ctx.globalAlpha *= c.opacity;
|
|
5673
|
+
if (c.rotate) {
|
|
5674
|
+
ctx.translate(c.x, c.y);
|
|
5675
|
+
ctx.rotate(c.rotate);
|
|
5676
|
+
ctx.translate(-c.x, -c.y);
|
|
5677
|
+
}
|
|
5678
|
+
ctx.font = c.font;
|
|
5679
|
+
ctx.textAlign = align;
|
|
5680
|
+
ctx.textBaseline = baseline;
|
|
5681
|
+
if (c.pill) {
|
|
5682
|
+
const padX = c.pill.padX ?? 7;
|
|
5683
|
+
const padY = c.pill.padY ?? 2;
|
|
5684
|
+
const tw = ctx.measureText(c.text).width;
|
|
5685
|
+
const th = c.size;
|
|
5686
|
+
let bx = c.x;
|
|
5687
|
+
if (align === "center") bx = c.x - tw / 2;
|
|
5688
|
+
else if (align === "right") bx = c.x - tw;
|
|
5689
|
+
let by = c.y;
|
|
5690
|
+
if (baseline === "middle") by = c.y - th / 2;
|
|
5691
|
+
else if (baseline === "alphabetic") by = c.y - th;
|
|
5692
|
+
ctx.beginPath();
|
|
5693
|
+
roundedRect(ctx, bx - padX, by - padY, tw + padX * 2, th + padY * 2, c.pill.radius ?? 999);
|
|
5694
|
+
ctx.fillStyle = c.pill.background;
|
|
5695
|
+
ctx.fill();
|
|
5696
|
+
if (c.pill.border) {
|
|
5697
|
+
ctx.lineWidth = 1;
|
|
5698
|
+
ctx.strokeStyle = c.pill.border;
|
|
5699
|
+
ctx.stroke();
|
|
5700
|
+
}
|
|
5701
|
+
}
|
|
5702
|
+
ctx.fillStyle = c.color;
|
|
5703
|
+
ctx.fillText(c.text, c.x, c.y);
|
|
5704
|
+
ctx.restore();
|
|
5705
|
+
}
|
|
5706
|
+
function overlayTextToCanvasCmd(o, font) {
|
|
5707
|
+
const t = o.transform ?? "";
|
|
5708
|
+
const rotate = t.includes("rotate(-90deg)") ? -Math.PI / 2 : t.includes("rotate(90deg)") ? Math.PI / 2 : (() => {
|
|
5709
|
+
const m = t.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
|
|
5710
|
+
return m ? parseFloat(m[1]) * Math.PI / 180 : void 0;
|
|
5711
|
+
})();
|
|
5712
|
+
let align = "left";
|
|
5713
|
+
let x = o.left;
|
|
5714
|
+
const y = o.top;
|
|
5715
|
+
if (o.width != null) {
|
|
5716
|
+
align = o.align ?? "left";
|
|
5717
|
+
x = align === "right" ? o.left + o.width : align === "center" ? o.left + o.width / 2 : o.left;
|
|
5718
|
+
} else if (t.includes("translateX(-100%)")) {
|
|
5719
|
+
align = "right";
|
|
5720
|
+
} else if (t.includes("translateX(-50%)") || t.includes("translate(-50%")) {
|
|
5721
|
+
align = "center";
|
|
5722
|
+
}
|
|
5723
|
+
const baseline = t.includes("translateY(-50%)") || t.includes(",-50%") || t.includes(", -50%") ? "middle" : "top";
|
|
5724
|
+
return {
|
|
5725
|
+
x,
|
|
5726
|
+
y,
|
|
5727
|
+
text: o.text,
|
|
5728
|
+
font,
|
|
5729
|
+
color: o.color,
|
|
5730
|
+
size: o.size,
|
|
5731
|
+
align,
|
|
5732
|
+
baseline,
|
|
5733
|
+
rotate,
|
|
5734
|
+
opacity: o.opacity,
|
|
5735
|
+
pill: o.pill
|
|
5736
|
+
};
|
|
5737
|
+
}
|
|
5738
|
+
function legendSwatchWidth(symbol, swatch = 11) {
|
|
5739
|
+
return symbol === "line" ? swatch + 3 : swatch;
|
|
5740
|
+
}
|
|
5741
|
+
function paintLegendSwatch(ctx, x, midY, symbol, color, swatch = 11) {
|
|
5742
|
+
ctx.save();
|
|
5743
|
+
ctx.fillStyle = color;
|
|
5744
|
+
ctx.beginPath();
|
|
5745
|
+
if (symbol === "circle") {
|
|
5746
|
+
ctx.arc(x + swatch / 2, midY, swatch / 2, 0, Math.PI * 2);
|
|
5747
|
+
} else if (symbol === "line") {
|
|
5748
|
+
roundedRect(ctx, x, midY - 1.5, swatch + 3, 3, 1.5);
|
|
5749
|
+
} else {
|
|
5750
|
+
roundedRect(ctx, x, midY - swatch / 2, swatch, swatch, 2);
|
|
5751
|
+
}
|
|
5752
|
+
ctx.fill();
|
|
5753
|
+
ctx.restore();
|
|
5754
|
+
}
|
|
5755
|
+
|
|
5756
|
+
// src/render/text.ts
|
|
5757
|
+
var measureCtx;
|
|
5758
|
+
var injectedCtx = null;
|
|
5759
|
+
function setMeasureContext(ctx) {
|
|
5760
|
+
injectedCtx = ctx;
|
|
5761
|
+
}
|
|
5762
|
+
function getMeasureCtx() {
|
|
5763
|
+
if (injectedCtx) return injectedCtx;
|
|
5764
|
+
if (measureCtx !== void 0) return measureCtx;
|
|
5765
|
+
try {
|
|
5766
|
+
measureCtx = document.createElement("canvas").getContext("2d");
|
|
5767
|
+
} catch {
|
|
5768
|
+
measureCtx = null;
|
|
5769
|
+
}
|
|
5770
|
+
return measureCtx;
|
|
5771
|
+
}
|
|
5772
|
+
function measureText(text, font) {
|
|
5773
|
+
const ctx = getMeasureCtx();
|
|
5774
|
+
if (!ctx) {
|
|
5775
|
+
const sizeMatch = /(\d+(?:\.\d+)?)px/.exec(font);
|
|
5776
|
+
const size = sizeMatch ? parseFloat(sizeMatch[1]) : 12;
|
|
5777
|
+
return { width: text.length * size * 0.55 };
|
|
5778
|
+
}
|
|
5779
|
+
ctx.font = font;
|
|
5780
|
+
return { width: ctx.measureText(text).width };
|
|
5781
|
+
}
|
|
5782
|
+
function fontString(size, family, weight = 400, style = "normal") {
|
|
5783
|
+
return `${style} ${weight} ${size}px ${family}`;
|
|
5784
|
+
}
|
|
5785
|
+
function ellipsize(text, maxWidth, font) {
|
|
5786
|
+
if (maxWidth <= 0 || text.length === 0) return "";
|
|
5787
|
+
if (measureText(text, font).width <= maxWidth) return text;
|
|
5788
|
+
const ellipsis = "\u2026";
|
|
5789
|
+
const ellipsisW = measureText(ellipsis, font).width;
|
|
5790
|
+
if (ellipsisW > maxWidth) return "";
|
|
5791
|
+
let lo = 0;
|
|
5792
|
+
let hi = text.length;
|
|
5793
|
+
while (lo < hi) {
|
|
5794
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
5795
|
+
if (measureText(text.slice(0, mid) + ellipsis, font).width <= maxWidth) lo = mid;
|
|
5796
|
+
else hi = mid - 1;
|
|
5797
|
+
}
|
|
5798
|
+
return text.slice(0, lo) + ellipsis;
|
|
5799
|
+
}
|
|
5800
|
+
|
|
5801
|
+
// src/theme/tokens.ts
|
|
5802
|
+
var PALETTE = categorical("graphein");
|
|
5803
|
+
var FONT_FAMILY = 'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
|
|
5804
|
+
var SHARED = {
|
|
5805
|
+
font: {
|
|
5806
|
+
family: FONT_FAMILY,
|
|
5807
|
+
size: { tiny: 10, small: 11, base: 12, large: 14, title: 16 },
|
|
5808
|
+
weight: { normal: 400, medium: 500, bold: 600 }
|
|
5809
|
+
},
|
|
5810
|
+
spacing: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 },
|
|
5811
|
+
radius: { sm: 4, md: 6, lg: 8 },
|
|
5812
|
+
stroke: { thin: 1, base: 1.5, thick: 2 }
|
|
5813
|
+
};
|
|
5814
|
+
var lightTheme = {
|
|
5815
|
+
name: "light",
|
|
5816
|
+
dark: false,
|
|
5817
|
+
color: {
|
|
5818
|
+
background: "#ffffff",
|
|
5819
|
+
surface: "#f8fafc",
|
|
5820
|
+
text: "#0f172a",
|
|
5821
|
+
textMuted: "#64748b",
|
|
5822
|
+
axis: "#cbd5e1",
|
|
5823
|
+
grid: "#eef2f6",
|
|
5824
|
+
border: "#e2e8f0",
|
|
5825
|
+
accent: "#0d9488",
|
|
5826
|
+
palette: PALETTE,
|
|
5827
|
+
positive: "#16a34a",
|
|
5828
|
+
negative: "#dc2626"
|
|
5829
|
+
},
|
|
5830
|
+
...SHARED
|
|
5831
|
+
};
|
|
5832
|
+
var darkTheme = {
|
|
5833
|
+
name: "dark",
|
|
5834
|
+
dark: true,
|
|
5835
|
+
color: {
|
|
5836
|
+
background: "#0b1220",
|
|
5837
|
+
surface: "#111a2e",
|
|
5838
|
+
text: "#f1f5f9",
|
|
5839
|
+
textMuted: "#94a3b8",
|
|
5840
|
+
axis: "#334155",
|
|
5841
|
+
grid: "#1c2740",
|
|
5842
|
+
border: "#1e293b",
|
|
5843
|
+
accent: "#2dd4bf",
|
|
5844
|
+
palette: PALETTE,
|
|
5845
|
+
positive: "#22c55e",
|
|
5846
|
+
negative: "#f87171"
|
|
5847
|
+
},
|
|
5848
|
+
...SHARED
|
|
5849
|
+
};
|
|
5850
|
+
var themes = {
|
|
5851
|
+
light: lightTheme,
|
|
5852
|
+
dark: darkTheme
|
|
5853
|
+
};
|
|
5854
|
+
function isPlainObject(v) {
|
|
5855
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5856
|
+
}
|
|
5857
|
+
function deepMerge(base, override) {
|
|
5858
|
+
if (!isPlainObject(override)) return base;
|
|
5859
|
+
if (!isPlainObject(base)) return override;
|
|
5860
|
+
const out = { ...base };
|
|
5861
|
+
for (const [key, value] of Object.entries(override)) {
|
|
5862
|
+
const baseValue = out[key];
|
|
5863
|
+
if (isPlainObject(baseValue) && isPlainObject(value)) {
|
|
5864
|
+
out[key] = deepMerge(baseValue, value);
|
|
5865
|
+
} else {
|
|
5866
|
+
out[key] = value;
|
|
5867
|
+
}
|
|
5868
|
+
}
|
|
5869
|
+
return out;
|
|
5870
|
+
}
|
|
5871
|
+
function resolveTheme(theme) {
|
|
5872
|
+
if (theme == null) return lightTheme;
|
|
5873
|
+
if (typeof theme === "string") return themes[theme] ?? lightTheme;
|
|
5874
|
+
const baseName = theme.base ?? (theme.dark ? "dark" : "light");
|
|
5875
|
+
const base = themes[baseName] ?? lightTheme;
|
|
5876
|
+
const { base: _ignored, ...override } = theme;
|
|
5877
|
+
return deepMerge(base, override);
|
|
5878
|
+
}
|
|
5879
|
+
|
|
5880
|
+
// src/theme/sketch.ts
|
|
5881
|
+
var SKETCH_FONT_NAME = "Patrick Hand";
|
|
5882
|
+
var SKETCH_FONT_FAMILY = `'Patrick Hand', 'Segoe Print', 'Bradley Hand', 'Comic Sans MS', ui-rounded, cursive`;
|
|
5883
|
+
var injected = false;
|
|
5884
|
+
function injectViaStyle(dataUrl) {
|
|
5885
|
+
const style = document.createElement("style");
|
|
5886
|
+
style.setAttribute("data-graphein-sketch-font", "");
|
|
5887
|
+
style.textContent = `@font-face{font-family:'${SKETCH_FONT_NAME}';font-style:normal;font-weight:400;font-display:swap;src:url("${dataUrl}") format("woff2");}`;
|
|
5888
|
+
document.head.appendChild(style);
|
|
5889
|
+
}
|
|
5890
|
+
async function ensureSketchFont() {
|
|
5891
|
+
if (injected) return false;
|
|
5892
|
+
if (typeof document === "undefined") return false;
|
|
5893
|
+
injected = true;
|
|
5894
|
+
try {
|
|
5895
|
+
const { PATRICK_HAND_DATA_URL } = await import('./patrickHand-ITDQJJE2.js');
|
|
5896
|
+
const docFonts = document.fonts;
|
|
5897
|
+
if (typeof FontFace !== "undefined" && docFonts?.add) {
|
|
5898
|
+
const face = new FontFace(
|
|
5899
|
+
SKETCH_FONT_NAME,
|
|
5900
|
+
`url("${PATRICK_HAND_DATA_URL}") format("woff2")`,
|
|
5901
|
+
{ style: "normal", weight: "400" }
|
|
5902
|
+
);
|
|
5903
|
+
await face.load();
|
|
5904
|
+
docFonts.add(face);
|
|
5905
|
+
return true;
|
|
5906
|
+
}
|
|
5907
|
+
injectViaStyle(PATRICK_HAND_DATA_URL);
|
|
5908
|
+
return true;
|
|
5909
|
+
} catch {
|
|
5910
|
+
injected = false;
|
|
5911
|
+
return false;
|
|
5912
|
+
}
|
|
5752
5913
|
}
|
|
5753
|
-
function
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5914
|
+
function withSketchFont(tokens) {
|
|
5915
|
+
return {
|
|
5916
|
+
...tokens,
|
|
5917
|
+
font: {
|
|
5918
|
+
...tokens.font,
|
|
5919
|
+
family: `${SKETCH_FONT_FAMILY}`
|
|
5920
|
+
}
|
|
5921
|
+
};
|
|
5922
|
+
}
|
|
5923
|
+
|
|
5924
|
+
// src/spec/infer.ts
|
|
5925
|
+
function looksTemporal2(value) {
|
|
5926
|
+
if (value.length < 4) return false;
|
|
5927
|
+
if (/^\d{4}(-\d{2}(-\d{2})?)?([T\s]\d{2}:\d{2})?/.test(value)) {
|
|
5928
|
+
const t = Date.parse(value);
|
|
5929
|
+
return !Number.isNaN(t);
|
|
5759
5930
|
}
|
|
5760
|
-
return
|
|
5931
|
+
return false;
|
|
5932
|
+
}
|
|
5933
|
+
function inferValueType(value) {
|
|
5934
|
+
if (value == null) return void 0;
|
|
5935
|
+
if (typeof value === "number") return Number.isFinite(value) ? "quantitative" : void 0;
|
|
5936
|
+
if (value instanceof Date) return "temporal";
|
|
5937
|
+
if (typeof value === "boolean") return "nominal";
|
|
5938
|
+
if (typeof value === "string") {
|
|
5939
|
+
if (value.trim() === "") return void 0;
|
|
5940
|
+
if (looksTemporal2(value)) return "temporal";
|
|
5941
|
+
if (/^-?\d*\.?\d+(e[-+]?\d+)?$/i.test(value.trim())) return "quantitative";
|
|
5942
|
+
return "nominal";
|
|
5943
|
+
}
|
|
5944
|
+
return "nominal";
|
|
5945
|
+
}
|
|
5946
|
+
function inferFieldType(data, field, sample = 50) {
|
|
5947
|
+
let seen = 0;
|
|
5948
|
+
const counts = {
|
|
5949
|
+
quantitative: 0,
|
|
5950
|
+
temporal: 0,
|
|
5951
|
+
ordinal: 0,
|
|
5952
|
+
nominal: 0
|
|
5953
|
+
};
|
|
5954
|
+
for (let i = 0; i < data.length && seen < sample; i++) {
|
|
5955
|
+
const t = inferValueType(data[i]?.[field]);
|
|
5956
|
+
if (!t) continue;
|
|
5957
|
+
counts[t]++;
|
|
5958
|
+
seen++;
|
|
5959
|
+
}
|
|
5960
|
+
if (seen === 0) return "nominal";
|
|
5961
|
+
if (counts.temporal >= seen * 0.6) return "temporal";
|
|
5962
|
+
if (counts.quantitative >= seen * 0.6) return "quantitative";
|
|
5963
|
+
return "nominal";
|
|
5964
|
+
}
|
|
5965
|
+
function inferFieldTypes(data) {
|
|
5966
|
+
const fields = /* @__PURE__ */ new Set();
|
|
5967
|
+
for (let i = 0; i < Math.min(data.length, 20); i++) {
|
|
5968
|
+
const row = data[i];
|
|
5969
|
+
if (row) for (const k of Object.keys(row)) fields.add(k);
|
|
5970
|
+
}
|
|
5971
|
+
const out = {};
|
|
5972
|
+
for (const f of fields) out[f] = inferFieldType(data, f);
|
|
5973
|
+
return out;
|
|
5761
5974
|
}
|
|
5762
5975
|
|
|
5763
5976
|
// src/spec/repair.ts
|
|
@@ -5784,10 +5997,10 @@ function repairSpec(spec) {
|
|
|
5784
5997
|
|
|
5785
5998
|
// src/spec/sketch.ts
|
|
5786
5999
|
function deriveSeed(spec) {
|
|
5787
|
-
const
|
|
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;
|
|
@@ -6032,6 +6245,10 @@ function yGridEnabled(model) {
|
|
|
6032
6245
|
return model.spec.axes?.y?.grid !== false;
|
|
6033
6246
|
}
|
|
6034
6247
|
function drawAxesUnderlay(surface, model) {
|
|
6248
|
+
if (model.orientation === "horizontal") {
|
|
6249
|
+
drawHorizontalAxesUnderlay(surface, model);
|
|
6250
|
+
return;
|
|
6251
|
+
}
|
|
6035
6252
|
const ctx = surface.marks.ctx;
|
|
6036
6253
|
const { plot, tokens } = model;
|
|
6037
6254
|
const x0 = plot.x;
|
|
@@ -6105,6 +6322,58 @@ function drawAxesUnderlay(surface, model) {
|
|
|
6105
6322
|
}
|
|
6106
6323
|
ctx.restore();
|
|
6107
6324
|
}
|
|
6325
|
+
function drawHorizontalAxesUnderlay(surface, model) {
|
|
6326
|
+
const ctx = surface.marks.ctx;
|
|
6327
|
+
const { plot, tokens } = model;
|
|
6328
|
+
const x0 = plot.x;
|
|
6329
|
+
const y0 = plot.y;
|
|
6330
|
+
const y1 = plot.y + plot.height;
|
|
6331
|
+
ctx.save();
|
|
6332
|
+
ctx.lineWidth = 1;
|
|
6333
|
+
const pen = model.sketch ? new RoughPen(ctx, model.sketch) : null;
|
|
6334
|
+
const seg = (sx0, sy0, sx1, sy1, stroke) => {
|
|
6335
|
+
pen.polyline([{ x: sx0, y: sy0 }, { x: sx1, y: sy1 }], {
|
|
6336
|
+
stroke,
|
|
6337
|
+
strokeWidth: 1,
|
|
6338
|
+
roughness: model.sketch.roughness * 0.5,
|
|
6339
|
+
bowing: model.sketch.bowing * 0.55
|
|
6340
|
+
});
|
|
6341
|
+
};
|
|
6342
|
+
if (yGridEnabled(model)) {
|
|
6343
|
+
if (pen) {
|
|
6344
|
+
for (const t of model.yTicks) seg(t.pos, y0, t.pos, y1, tokens.color.grid);
|
|
6345
|
+
} else {
|
|
6346
|
+
ctx.strokeStyle = tokens.color.grid;
|
|
6347
|
+
ctx.beginPath();
|
|
6348
|
+
for (const t of model.yTicks) {
|
|
6349
|
+
const x = crisp(t.pos);
|
|
6350
|
+
ctx.moveTo(x, y0);
|
|
6351
|
+
ctx.lineTo(x, y1);
|
|
6352
|
+
}
|
|
6353
|
+
ctx.stroke();
|
|
6354
|
+
}
|
|
6355
|
+
}
|
|
6356
|
+
if (model.spec.axes?.x?.show !== false) {
|
|
6357
|
+
if (pen) {
|
|
6358
|
+
seg(x0, y0, x0, y1, tokens.color.axis);
|
|
6359
|
+
for (const t of model.xTicks) seg(x0 - TICK_SIZE, t.pos, x0, t.pos, tokens.color.axis);
|
|
6360
|
+
} else {
|
|
6361
|
+
ctx.strokeStyle = tokens.color.axis;
|
|
6362
|
+
ctx.beginPath();
|
|
6363
|
+
ctx.moveTo(crisp(x0), y0);
|
|
6364
|
+
ctx.lineTo(crisp(x0), y1);
|
|
6365
|
+
ctx.stroke();
|
|
6366
|
+
ctx.beginPath();
|
|
6367
|
+
for (const t of model.xTicks) {
|
|
6368
|
+
const y = crisp(t.pos);
|
|
6369
|
+
ctx.moveTo(x0 - TICK_SIZE, y);
|
|
6370
|
+
ctx.lineTo(x0, y);
|
|
6371
|
+
}
|
|
6372
|
+
ctx.stroke();
|
|
6373
|
+
}
|
|
6374
|
+
}
|
|
6375
|
+
ctx.restore();
|
|
6376
|
+
}
|
|
6108
6377
|
function thinXTicks(model, avgCharPx) {
|
|
6109
6378
|
const ticks2 = model.xTicks;
|
|
6110
6379
|
if (ticks2.length <= 1) return ticks2;
|
|
@@ -6175,9 +6444,12 @@ function drawOverlay(surface, model) {
|
|
|
6175
6444
|
const { plot, tokens, frame } = model;
|
|
6176
6445
|
const f = tokens.font;
|
|
6177
6446
|
const smallFont = fontString(f.size.small, f.family, f.weight.normal);
|
|
6178
|
-
|
|
6447
|
+
const horizontal = model.orientation === "horizontal";
|
|
6448
|
+
const gutterTicks = horizontal ? model.xTicks : model.yTicks;
|
|
6449
|
+
const gutterLabelsOn = horizontal ? model.spec.axes?.x?.labels !== false : model.spec.axes?.y?.labels !== false;
|
|
6450
|
+
if (gutterLabelsOn) {
|
|
6179
6451
|
const gutterRight = plot.x - TICK_SIZE - 4;
|
|
6180
|
-
for (const t of
|
|
6452
|
+
for (const t of gutterTicks) {
|
|
6181
6453
|
addText(surface, smallFont, {
|
|
6182
6454
|
left: 0,
|
|
6183
6455
|
top: t.pos,
|
|
@@ -6190,20 +6462,34 @@ function drawOverlay(surface, model) {
|
|
|
6190
6462
|
});
|
|
6191
6463
|
}
|
|
6192
6464
|
}
|
|
6193
|
-
|
|
6465
|
+
const bottomLabelsOn = horizontal ? model.spec.axes?.y?.labels !== false : model.spec.axes?.x?.labels !== false;
|
|
6466
|
+
if (bottomLabelsOn) {
|
|
6194
6467
|
const top = plot.y + plot.height + TICK_SIZE + 3;
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6468
|
+
if (horizontal) {
|
|
6469
|
+
for (const p of placeXLabels(model.yTicks, frame.originX, frame.width, smallFont)) {
|
|
6470
|
+
addText(surface, smallFont, {
|
|
6471
|
+
left: p.left,
|
|
6472
|
+
top,
|
|
6473
|
+
text: p.text,
|
|
6474
|
+
color: tokens.color.textMuted,
|
|
6475
|
+
size: f.size.small,
|
|
6476
|
+
transform: p.transform
|
|
6477
|
+
});
|
|
6478
|
+
}
|
|
6479
|
+
} else {
|
|
6480
|
+
const rotated = frame.xLabelAngle > 0;
|
|
6481
|
+
for (const p of resolveXLabels(model)) {
|
|
6482
|
+
addText(surface, smallFont, {
|
|
6483
|
+
left: p.left,
|
|
6484
|
+
top,
|
|
6485
|
+
text: p.text,
|
|
6486
|
+
color: tokens.color.textMuted,
|
|
6487
|
+
size: f.size.small,
|
|
6488
|
+
align: rotated ? "right" : void 0,
|
|
6489
|
+
transform: p.transform,
|
|
6490
|
+
transformOrigin: rotated ? "100% 0%" : void 0
|
|
6491
|
+
});
|
|
6492
|
+
}
|
|
6207
6493
|
}
|
|
6208
6494
|
}
|
|
6209
6495
|
drawAxisTitles(surface, model);
|
|
@@ -6215,6 +6501,32 @@ function drawAxisTitles(surface, model) {
|
|
|
6215
6501
|
const f = tokens.font;
|
|
6216
6502
|
const baseFont = fontString(f.size.base, f.family, f.weight.medium);
|
|
6217
6503
|
const xTitle = model.spec.axes?.x?.title ?? model.spec.encoding.x.title;
|
|
6504
|
+
const yTitle = model.spec.axes?.y?.title ?? model.spec.encoding.y.title;
|
|
6505
|
+
if (model.orientation === "horizontal") {
|
|
6506
|
+
if (yTitle) {
|
|
6507
|
+
addText(surface, baseFont, {
|
|
6508
|
+
left: plot.x + plot.width / 2,
|
|
6509
|
+
top: frame.originY + frame.height - Math.round(f.size.base * 1.35),
|
|
6510
|
+
text: yTitle,
|
|
6511
|
+
color: tokens.color.text,
|
|
6512
|
+
size: f.size.base,
|
|
6513
|
+
weight: f.weight.medium,
|
|
6514
|
+
transform: "translateX(-50%)"
|
|
6515
|
+
});
|
|
6516
|
+
}
|
|
6517
|
+
if (xTitle) {
|
|
6518
|
+
addText(surface, baseFont, {
|
|
6519
|
+
left: frame.originX + Math.round(f.size.base * 0.4),
|
|
6520
|
+
top: plot.y + plot.height / 2,
|
|
6521
|
+
text: xTitle,
|
|
6522
|
+
color: tokens.color.text,
|
|
6523
|
+
size: f.size.base,
|
|
6524
|
+
weight: f.weight.medium,
|
|
6525
|
+
transform: "translate(-50%, -50%) rotate(-90deg)"
|
|
6526
|
+
});
|
|
6527
|
+
}
|
|
6528
|
+
return;
|
|
6529
|
+
}
|
|
6218
6530
|
if (xTitle) {
|
|
6219
6531
|
addText(surface, baseFont, {
|
|
6220
6532
|
left: plot.x + plot.width / 2,
|
|
@@ -6226,7 +6538,6 @@ function drawAxisTitles(surface, model) {
|
|
|
6226
6538
|
transform: "translateX(-50%)"
|
|
6227
6539
|
});
|
|
6228
6540
|
}
|
|
6229
|
-
const yTitle = model.spec.axes?.y?.title ?? model.spec.encoding.y.title;
|
|
6230
6541
|
if (yTitle) {
|
|
6231
6542
|
addText(surface, baseFont, {
|
|
6232
6543
|
left: frame.originX + Math.round(f.size.base * 0.4),
|
|
@@ -6242,25 +6553,25 @@ function drawAxisTitles(surface, model) {
|
|
|
6242
6553
|
function drawTitle(surface, frame, spec, tokens) {
|
|
6243
6554
|
if (!frame.titleRect) return;
|
|
6244
6555
|
const f = tokens.font;
|
|
6245
|
-
const
|
|
6556
|
+
const title2 = typeof spec.title === "string" ? { text: spec.title } : spec.title ?? {};
|
|
6246
6557
|
const align = typeof spec.title === "object" && spec.title?.align || "left";
|
|
6247
6558
|
const titleFont = fontString(f.size.title, f.family, f.weight.bold);
|
|
6248
6559
|
addText(surface, titleFont, {
|
|
6249
6560
|
left: frame.titleRect.x,
|
|
6250
6561
|
top: frame.titleRect.y,
|
|
6251
6562
|
width: frame.titleRect.width,
|
|
6252
|
-
text:
|
|
6563
|
+
text: title2.text ?? "",
|
|
6253
6564
|
color: tokens.color.text,
|
|
6254
6565
|
size: f.size.title,
|
|
6255
6566
|
weight: f.weight.bold,
|
|
6256
6567
|
align
|
|
6257
6568
|
});
|
|
6258
|
-
if (frame.subtitleRect &&
|
|
6569
|
+
if (frame.subtitleRect && title2.subtitle) {
|
|
6259
6570
|
addText(surface, fontString(f.size.small, f.family, f.weight.normal), {
|
|
6260
6571
|
left: frame.subtitleRect.x,
|
|
6261
6572
|
top: frame.subtitleRect.y,
|
|
6262
6573
|
width: frame.subtitleRect.width,
|
|
6263
|
-
text:
|
|
6574
|
+
text: title2.subtitle,
|
|
6264
6575
|
color: tokens.color.textMuted,
|
|
6265
6576
|
size: f.size.small,
|
|
6266
6577
|
align
|
|
@@ -6418,6 +6729,10 @@ function formatNumericTick(value, userFormat) {
|
|
|
6418
6729
|
return formatNumber2(value, ",");
|
|
6419
6730
|
}
|
|
6420
6731
|
var DEFAULT_PADDING = { top: 12, right: 16, bottom: 12, left: 12 };
|
|
6732
|
+
var LEGEND_SWATCH2 = 11;
|
|
6733
|
+
function legendInteractive(spec) {
|
|
6734
|
+
return typeof spec.legend === "object" && spec.legend.interactive === true;
|
|
6735
|
+
}
|
|
6421
6736
|
function buildCartesianModel(spec, tokens, opts) {
|
|
6422
6737
|
const data = spec.data ?? [];
|
|
6423
6738
|
const enc = spec.encoding;
|
|
@@ -6425,10 +6740,11 @@ function buildCartesianModel(spec, tokens, opts) {
|
|
|
6425
6740
|
const yField = enc.y.field;
|
|
6426
6741
|
const xType = fieldType(spec, enc.x, "nominal");
|
|
6427
6742
|
const xKind = xKindFor(spec, xType);
|
|
6743
|
+
const horizontal = spec.type === "bar" && spec.orientation === "horizontal";
|
|
6428
6744
|
const seriesField = resolveSeriesField(spec);
|
|
6429
6745
|
const rawSeries = groupBySeries(data, seriesField);
|
|
6430
6746
|
const seriesKeys = rawSeries.map((s) => s.key);
|
|
6431
|
-
const palette = tokens.color.palette;
|
|
6747
|
+
const palette = Array.isArray(spec.palette) ? spec.palette : typeof spec.palette === "string" ? categorical(spec.palette) : tokens.color.palette;
|
|
6432
6748
|
const colorScale = ordinalColorScale({ domain: seriesKeys, palette });
|
|
6433
6749
|
const colorCache = /* @__PURE__ */ new Map();
|
|
6434
6750
|
const sharedColor = opts.shared?.colorOf;
|
|
@@ -6580,6 +6896,19 @@ function buildCartesianModel(spec, tokens, opts) {
|
|
|
6580
6896
|
}));
|
|
6581
6897
|
const padding = { ...DEFAULT_PADDING, ...spec.padding };
|
|
6582
6898
|
const titleInput4 = resolveTitle(spec);
|
|
6899
|
+
const categoryAxisInput = {
|
|
6900
|
+
show: xShow,
|
|
6901
|
+
labels: xLabels,
|
|
6902
|
+
title: resolveAxisTitle(enc.x, xAxisCfg),
|
|
6903
|
+
edgeAnchored: !categories,
|
|
6904
|
+
labelAngle: xAxisCfg.labelAngle
|
|
6905
|
+
};
|
|
6906
|
+
const valueAxisInput = {
|
|
6907
|
+
show: yShow,
|
|
6908
|
+
labels: yLabels,
|
|
6909
|
+
title: resolveAxisTitle(enc.y, yAxisCfg),
|
|
6910
|
+
edgeAnchored: true
|
|
6911
|
+
};
|
|
6583
6912
|
const frame = computeFrame({
|
|
6584
6913
|
width: opts.width,
|
|
6585
6914
|
height: opts.height,
|
|
@@ -6589,20 +6918,37 @@ function buildCartesianModel(spec, tokens, opts) {
|
|
|
6589
6918
|
font: tokens.font,
|
|
6590
6919
|
title: titleInput4,
|
|
6591
6920
|
legend: legend.show && legendItems.length > 1 ? { items: legendItems, position: legend.position } : void 0,
|
|
6592
|
-
xAxis:
|
|
6593
|
-
yAxis:
|
|
6921
|
+
xAxis: horizontal ? valueAxisInput : categoryAxisInput,
|
|
6922
|
+
yAxis: horizontal ? categoryAxisInput : valueAxisInput
|
|
6594
6923
|
});
|
|
6924
|
+
const legendHits = legendInteractive(spec) && seriesField && frame.legendItems ? frame.legendItems.map((item, i) => {
|
|
6925
|
+
const rowH = Math.max(LEGEND_SWATCH2, Math.round(tokens.font.size.small * 1.35));
|
|
6926
|
+
const midY = item.y + LEGEND_SWATCH2 / 2;
|
|
6927
|
+
return {
|
|
6928
|
+
rect: {
|
|
6929
|
+
x: item.x,
|
|
6930
|
+
y: midY - rowH / 2,
|
|
6931
|
+
width: item.width,
|
|
6932
|
+
height: rowH
|
|
6933
|
+
},
|
|
6934
|
+
key: series[i]?.key ?? item.label,
|
|
6935
|
+
value: series[i]?.value ?? item.label,
|
|
6936
|
+
label: item.label
|
|
6937
|
+
};
|
|
6938
|
+
}) : void 0;
|
|
6595
6939
|
const plot = frame.plot;
|
|
6596
|
-
const
|
|
6597
|
-
const yScale = yIsLog ? logScale({ domain: yDomain, range:
|
|
6940
|
+
const valueRange = horizontal ? [plot.x, plot.x + plot.width] : [plot.y + plot.height, plot.y];
|
|
6941
|
+
const yScale = yIsLog ? logScale({ domain: yDomain, range: valueRange, base: yBase, clamp: yClamp }) : linearScale({ domain: yDomain, range: valueRange, clamp: yClamp });
|
|
6598
6942
|
const yPixel = (value) => yScale.map(toNumber(value));
|
|
6599
|
-
const
|
|
6943
|
+
const valueMin = horizontal ? plot.x : plot.y;
|
|
6944
|
+
const valueMax = horizontal ? plot.x + plot.width : plot.y + plot.height;
|
|
6945
|
+
const baseline = yIsLog ? horizontal ? plot.x : plot.y + plot.height : clampPx(yScale.map(0), valueMin, valueMax);
|
|
6600
6946
|
const xModel = buildXModel(xKind, {
|
|
6601
6947
|
field: xField,
|
|
6602
6948
|
type: xType,
|
|
6603
6949
|
categories,
|
|
6604
6950
|
domainNum: xDomainNum,
|
|
6605
|
-
range: [plot.x, plot.x + plot.width],
|
|
6951
|
+
range: horizontal ? [plot.y, plot.y + plot.height] : [plot.x, plot.x + plot.width],
|
|
6606
6952
|
paddingInner: spec.type === "bar" ? 0.2 : spec.type === "box" ? 0.32 : 0.5,
|
|
6607
6953
|
scaleCfg: enc.x.scale
|
|
6608
6954
|
});
|
|
@@ -6625,9 +6971,12 @@ function buildCartesianModel(spec, tokens, opts) {
|
|
|
6625
6971
|
series,
|
|
6626
6972
|
seriesField,
|
|
6627
6973
|
stacked,
|
|
6974
|
+
orientation: horizontal ? "horizontal" : "vertical",
|
|
6975
|
+
project: horizontal ? (catPixel, valPixel) => ({ x: valPixel, y: catPixel }) : (catPixel, valPixel) => ({ x: catPixel, y: valPixel }),
|
|
6628
6976
|
xTicks,
|
|
6629
6977
|
yTicks,
|
|
6630
6978
|
colorOf,
|
|
6979
|
+
legendHits,
|
|
6631
6980
|
sketch: resolveSketch(spec)
|
|
6632
6981
|
};
|
|
6633
6982
|
}
|
|
@@ -7021,14 +7370,11 @@ function finite2(value) {
|
|
|
7021
7370
|
function defaultRadius(model, spec) {
|
|
7022
7371
|
return Math.max(0, spec.cornerRadius ?? Math.min(4, model.tokens.radius.sm));
|
|
7023
7372
|
}
|
|
7024
|
-
function cornerSideFor(baseY, valueY) {
|
|
7025
|
-
if (valueY < baseY) return "top";
|
|
7026
|
-
if (valueY > baseY) return "bottom";
|
|
7027
|
-
return "none";
|
|
7028
|
-
}
|
|
7029
7373
|
function makeRadii(side, radius) {
|
|
7030
7374
|
if (side === "top") return [radius, radius, 0, 0];
|
|
7031
7375
|
if (side === "bottom") return [0, 0, radius, radius];
|
|
7376
|
+
if (side === "right") return [0, radius, radius, 0];
|
|
7377
|
+
if (side === "left") return [radius, 0, 0, radius];
|
|
7032
7378
|
return 0;
|
|
7033
7379
|
}
|
|
7034
7380
|
function drawRect(ctx, rect, radius, pen, alpha = 1) {
|
|
@@ -7048,28 +7394,39 @@ function drawRect(ctx, rect, radius, pen, alpha = 1) {
|
|
|
7048
7394
|
ctx.fill();
|
|
7049
7395
|
if (alpha !== 1) ctx.globalAlpha = prevAlpha;
|
|
7050
7396
|
}
|
|
7051
|
-
function
|
|
7052
|
-
if (!finite2(
|
|
7053
|
-
const
|
|
7054
|
-
const
|
|
7055
|
-
if (
|
|
7397
|
+
function orientedRect(horizontal, catStart, catThick, base, value, color) {
|
|
7398
|
+
if (!finite2(catStart) || !finite2(catThick) || !finite2(base) || !finite2(value)) return null;
|
|
7399
|
+
const lo = Math.min(base, value);
|
|
7400
|
+
const len = Math.abs(value - base);
|
|
7401
|
+
if (catThick <= 0 || len <= 0) return null;
|
|
7402
|
+
if (horizontal) {
|
|
7403
|
+
return {
|
|
7404
|
+
x: lo,
|
|
7405
|
+
y: catStart,
|
|
7406
|
+
width: len,
|
|
7407
|
+
height: catThick,
|
|
7408
|
+
color,
|
|
7409
|
+
cornerSide: value > base ? "right" : value < base ? "left" : "none"
|
|
7410
|
+
};
|
|
7411
|
+
}
|
|
7056
7412
|
return {
|
|
7057
|
-
x,
|
|
7058
|
-
y,
|
|
7059
|
-
width,
|
|
7060
|
-
height,
|
|
7413
|
+
x: catStart,
|
|
7414
|
+
y: lo,
|
|
7415
|
+
width: catThick,
|
|
7416
|
+
height: len,
|
|
7061
7417
|
color,
|
|
7062
|
-
cornerSide:
|
|
7418
|
+
cornerSide: value < base ? "top" : value > base ? "bottom" : "none"
|
|
7063
7419
|
};
|
|
7064
7420
|
}
|
|
7065
7421
|
function drawGroupedBars(ctx, model, radius, pen) {
|
|
7066
7422
|
const readX = accessor(model.x.field);
|
|
7067
7423
|
const readY = accessor(model.y.field ?? "");
|
|
7424
|
+
const horizontal = model.orientation === "horizontal";
|
|
7068
7425
|
const seriesCount = model.series.length;
|
|
7069
7426
|
const bandWidth = model.x.bandwidth;
|
|
7070
7427
|
const subWidth = seriesCount > 1 ? bandWidth / seriesCount : bandWidth;
|
|
7071
7428
|
const gap = seriesCount > 1 && subWidth > 3 ? Math.min(1, subWidth * 0.15) : 0;
|
|
7072
|
-
const
|
|
7429
|
+
const barThick = Math.max(0, subWidth - gap);
|
|
7073
7430
|
model.series.forEach((series, seriesIndex) => {
|
|
7074
7431
|
ctx.fillStyle = series.color;
|
|
7075
7432
|
for (const row of series.rows) {
|
|
@@ -7078,8 +7435,8 @@ function drawGroupedBars(ctx, model, radius, pen) {
|
|
|
7078
7435
|
if (raw == null || raw === "" || !finite2(value)) continue;
|
|
7079
7436
|
const center2 = model.x.pixel(readX(row));
|
|
7080
7437
|
if (center2 == null) continue;
|
|
7081
|
-
const
|
|
7082
|
-
const rect =
|
|
7438
|
+
const catStart = center2 - bandWidth / 2 + seriesIndex * subWidth + gap / 2;
|
|
7439
|
+
const rect = orientedRect(horizontal, catStart, barThick, model.y.baseline, model.y.pixel(value), series.color);
|
|
7083
7440
|
if (rect) drawRect(ctx, rect, radius, pen, rowAlpha(model.emphasis, row));
|
|
7084
7441
|
}
|
|
7085
7442
|
});
|
|
@@ -7087,6 +7444,7 @@ function drawGroupedBars(ctx, model, radius, pen) {
|
|
|
7087
7444
|
function drawStackedBars(ctx, model, radius, pen) {
|
|
7088
7445
|
const readX = accessor(model.x.field);
|
|
7089
7446
|
const readY = accessor(model.y.field ?? "");
|
|
7447
|
+
const horizontal = model.orientation === "horizontal";
|
|
7090
7448
|
const positiveTotals = /* @__PURE__ */ new Map();
|
|
7091
7449
|
const negativeTotals = /* @__PURE__ */ new Map();
|
|
7092
7450
|
const segments = [];
|
|
@@ -7106,7 +7464,7 @@ function drawStackedBars(ctx, model, radius, pen) {
|
|
|
7106
7464
|
const base = totals.get(categoryKey2) ?? 0;
|
|
7107
7465
|
const top = base + value;
|
|
7108
7466
|
totals.set(categoryKey2, top);
|
|
7109
|
-
const rect =
|
|
7467
|
+
const rect = orientedRect(horizontal, center2 - bandWidth / 2, bandWidth, model.y.pixel(base), model.y.pixel(top), series.color);
|
|
7110
7468
|
if (!rect) continue;
|
|
7111
7469
|
segments.push({ ...rect, stackEndKey, alpha: rowAlpha(model.emphasis, row) });
|
|
7112
7470
|
roundedEnds.set(stackEndKey, segments.length - 1);
|
|
@@ -7137,6 +7495,8 @@ function drawBar(surface, model) {
|
|
|
7137
7495
|
}
|
|
7138
7496
|
|
|
7139
7497
|
// src/charts/scatter.ts
|
|
7498
|
+
var QUANTIZE_MIN_POINTS = 4e3;
|
|
7499
|
+
var QUANTIZE_AREA_DIVISOR = 4;
|
|
7140
7500
|
function isMissing(value) {
|
|
7141
7501
|
return value == null || value === "";
|
|
7142
7502
|
}
|
|
@@ -7146,6 +7506,35 @@ function isInvalidPositionValue(value) {
|
|
|
7146
7506
|
function fixedRadius() {
|
|
7147
7507
|
return 3.75;
|
|
7148
7508
|
}
|
|
7509
|
+
function shouldQuantize(count, plot, hasSize) {
|
|
7510
|
+
return !hasSize && count > QUANTIZE_MIN_POINTS && count > plot.width * plot.height / QUANTIZE_AREA_DIVISOR;
|
|
7511
|
+
}
|
|
7512
|
+
function quantizePoints(points) {
|
|
7513
|
+
if (points.length === 0) return [];
|
|
7514
|
+
let minX = Infinity;
|
|
7515
|
+
let maxX = -Infinity;
|
|
7516
|
+
let minY = Infinity;
|
|
7517
|
+
let maxY = -Infinity;
|
|
7518
|
+
for (const p of points) {
|
|
7519
|
+
if (p.x < minX) minX = p.x;
|
|
7520
|
+
if (p.x > maxX) maxX = p.x;
|
|
7521
|
+
if (p.y < minY) minY = p.y;
|
|
7522
|
+
if (p.y > maxY) maxY = p.y;
|
|
7523
|
+
}
|
|
7524
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7525
|
+
const keep = [];
|
|
7526
|
+
for (const p of points) {
|
|
7527
|
+
const extentPoint = p.x === minX || p.x === maxX || p.y === minY || p.y === maxY;
|
|
7528
|
+
const ix = Math.round(p.x);
|
|
7529
|
+
const iy = Math.round(p.y);
|
|
7530
|
+
const key = `${p.seriesKey}|${ix}|${iy}`;
|
|
7531
|
+
if (extentPoint || !seen.has(key)) {
|
|
7532
|
+
keep.push(p);
|
|
7533
|
+
seen.add(key);
|
|
7534
|
+
}
|
|
7535
|
+
}
|
|
7536
|
+
return keep;
|
|
7537
|
+
}
|
|
7149
7538
|
function radiusScale(rows, field, plotSize) {
|
|
7150
7539
|
const rMin = 3;
|
|
7151
7540
|
const rMax = clamp(plotSize * 0.05, 10, 26);
|
|
@@ -7188,18 +7577,22 @@ function drawScatter(surface, model) {
|
|
|
7188
7577
|
y: py,
|
|
7189
7578
|
r: readSize ? radius(readSize(row)) : radius(),
|
|
7190
7579
|
color: series.color,
|
|
7191
|
-
alpha: rowAlpha(model.emphasis, row)
|
|
7580
|
+
alpha: rowAlpha(model.emphasis, row),
|
|
7581
|
+
seriesKey: series.key
|
|
7192
7582
|
});
|
|
7193
7583
|
}
|
|
7194
7584
|
}
|
|
7195
|
-
|
|
7585
|
+
const originalCount = points.length;
|
|
7586
|
+
const drawPoints = shouldQuantize(originalCount, plot, !!sizeField) ? quantizePoints(points) : points;
|
|
7587
|
+
model.markStats = { drawn: drawPoints.length, original: originalCount };
|
|
7588
|
+
if (sizeField) drawPoints.sort((a, b) => b.r - a.r);
|
|
7196
7589
|
ctx.save();
|
|
7197
7590
|
ctx.beginPath();
|
|
7198
7591
|
ctx.rect(plot.x, plot.y, plot.width, plot.height);
|
|
7199
7592
|
ctx.clip();
|
|
7200
7593
|
if (model.sketch) {
|
|
7201
7594
|
const pen = new RoughPen(ctx, model.sketch);
|
|
7202
|
-
for (const point of
|
|
7595
|
+
for (const point of drawPoints) {
|
|
7203
7596
|
const prev = ctx.globalAlpha;
|
|
7204
7597
|
if (point.alpha !== 1) ctx.globalAlpha = point.alpha;
|
|
7205
7598
|
pen.circle(point.x, point.y, point.r, {
|
|
@@ -7215,7 +7608,7 @@ function drawScatter(surface, model) {
|
|
|
7215
7608
|
}
|
|
7216
7609
|
ctx.lineWidth = 1;
|
|
7217
7610
|
ctx.strokeStyle = tokens.color.background;
|
|
7218
|
-
for (const point of
|
|
7611
|
+
for (const point of drawPoints) {
|
|
7219
7612
|
ctx.beginPath();
|
|
7220
7613
|
ctx.arc(point.x, point.y, point.r, 0, Math.PI * 2);
|
|
7221
7614
|
ctx.fillStyle = point.color;
|
|
@@ -7473,10 +7866,10 @@ function buildBoxInteraction(model) {
|
|
|
7473
7866
|
|
|
7474
7867
|
// src/charts/chrome.ts
|
|
7475
7868
|
var CHROME_PAD = { top: 14, right: 16, bottom: 14, left: 16 };
|
|
7476
|
-
function resolveTitle2(
|
|
7477
|
-
if (typeof
|
|
7478
|
-
if (
|
|
7479
|
-
const t =
|
|
7869
|
+
function resolveTitle2(title2) {
|
|
7870
|
+
if (typeof title2 === "string") return { text: title2, align: "left" };
|
|
7871
|
+
if (title2 && typeof title2 === "object") {
|
|
7872
|
+
const t = title2;
|
|
7480
7873
|
return { text: t.text, subtitle: t.subtitle, align: t.align ?? "left" };
|
|
7481
7874
|
}
|
|
7482
7875
|
return { align: "left" };
|
|
@@ -7531,9 +7924,9 @@ function addOverlayText(surface, tokens, o) {
|
|
|
7531
7924
|
surface.overlay.appendChild(el);
|
|
7532
7925
|
return el;
|
|
7533
7926
|
}
|
|
7534
|
-
function drawTitleBlock(surface, tokens, size,
|
|
7927
|
+
function drawTitleBlock(surface, tokens, size, title2) {
|
|
7535
7928
|
const f = tokens.font;
|
|
7536
|
-
const t = resolveTitle2(
|
|
7929
|
+
const t = resolveTitle2(title2);
|
|
7537
7930
|
let top = CHROME_PAD.top;
|
|
7538
7931
|
const innerWidth = size.width - CHROME_PAD.left - CHROME_PAD.right;
|
|
7539
7932
|
if (t.text) {
|
|
@@ -9912,11 +10305,11 @@ function buildHeader(opts) {
|
|
|
9912
10305
|
thead.appendChild(tr);
|
|
9913
10306
|
return thead;
|
|
9914
10307
|
}
|
|
9915
|
-
function makeHeaderCell(opts,
|
|
10308
|
+
function makeHeaderCell(opts, title2, colIndex, align, rowIndex = 0) {
|
|
9916
10309
|
const th = document.createElement("th");
|
|
9917
10310
|
const metrics = densityMetrics(opts.density);
|
|
9918
10311
|
th.scope = "col";
|
|
9919
|
-
th.textContent =
|
|
10312
|
+
th.textContent = title2;
|
|
9920
10313
|
const isSorted = colIndex != null && opts.sortState?.col === colIndex;
|
|
9921
10314
|
if (isSorted) th.setAttribute("aria-sort", opts.sortState?.dir === "desc" ? "descending" : "ascending");
|
|
9922
10315
|
setCellBaseStyles(th, opts.tokens, align ?? "left", opts.stickyHeader !== false, metrics.padding);
|
|
@@ -9948,7 +10341,7 @@ function makeHeaderCell(opts, title, colIndex, align, rowIndex = 0) {
|
|
|
9948
10341
|
font: "inherit"
|
|
9949
10342
|
});
|
|
9950
10343
|
const label = document.createElement("span");
|
|
9951
|
-
label.textContent =
|
|
10344
|
+
label.textContent = title2;
|
|
9952
10345
|
label.style.overflow = "hidden";
|
|
9953
10346
|
label.style.textOverflow = "ellipsis";
|
|
9954
10347
|
const glyph = document.createElement("span");
|
|
@@ -10777,10 +11170,10 @@ function prepareMatrix(spec) {
|
|
|
10777
11170
|
},
|
|
10778
11171
|
...leafValueColumns.map(({ leaf, valueIndex }) => {
|
|
10779
11172
|
const valueDef = spec.values[valueIndex];
|
|
10780
|
-
const
|
|
11173
|
+
const title2 = spec.values.length > 1 ? valueDef.label ?? valueDef.field : leaf.key;
|
|
10781
11174
|
return {
|
|
10782
11175
|
key: `${leaf.path.join(PATH_SEPARATOR2)}:${valueIndex}`,
|
|
10783
|
-
title,
|
|
11176
|
+
title: title2,
|
|
10784
11177
|
align: "right",
|
|
10785
11178
|
type: "quantitative",
|
|
10786
11179
|
format: valueDef.showAs && valueDef.showAs !== "value" ? ".1%" : valueDef.format,
|
|
@@ -10978,8 +11371,8 @@ function mountSlicerShell(surface, tokens, size, opts = {}) {
|
|
|
10978
11371
|
color: c.text,
|
|
10979
11372
|
font: fontString(tokens.font.size.base, tokens.font.family, tokens.font.weight.normal)
|
|
10980
11373
|
});
|
|
10981
|
-
const
|
|
10982
|
-
const labelText2 =
|
|
11374
|
+
const title2 = resolveTitle2(opts.title);
|
|
11375
|
+
const labelText2 = title2.text ?? opts.label;
|
|
10983
11376
|
const header = document.createElement("div");
|
|
10984
11377
|
Object.assign(header.style, {
|
|
10985
11378
|
display: "flex",
|
|
@@ -10993,16 +11386,16 @@ function mountSlicerShell(surface, tokens, size, opts = {}) {
|
|
|
10993
11386
|
labelEl.textContent = labelText2 ?? "";
|
|
10994
11387
|
Object.assign(labelEl.style, {
|
|
10995
11388
|
font: fontString(
|
|
10996
|
-
|
|
11389
|
+
title2.text ? tokens.font.size.large : tokens.font.size.small,
|
|
10997
11390
|
tokens.font.family,
|
|
10998
|
-
|
|
11391
|
+
title2.text ? tokens.font.weight.bold : tokens.font.weight.medium
|
|
10999
11392
|
),
|
|
11000
|
-
color:
|
|
11393
|
+
color: title2.text ? c.text : c.textMuted,
|
|
11001
11394
|
whiteSpace: "nowrap",
|
|
11002
11395
|
overflow: "hidden",
|
|
11003
11396
|
textOverflow: "ellipsis"
|
|
11004
11397
|
});
|
|
11005
|
-
if (
|
|
11398
|
+
if (title2.subtitle) labelEl.title = title2.subtitle;
|
|
11006
11399
|
const clearSlot = document.createElement("div");
|
|
11007
11400
|
clearSlot.style.flex = "0 0 auto";
|
|
11008
11401
|
header.appendChild(labelEl);
|
|
@@ -11104,8 +11497,8 @@ function makePopover(tokens) {
|
|
|
11104
11497
|
const c = tokens.color;
|
|
11105
11498
|
const pop = document.createElement("div");
|
|
11106
11499
|
Object.assign(pop.style, {
|
|
11107
|
-
position: "
|
|
11108
|
-
zIndex: "
|
|
11500
|
+
position: "fixed",
|
|
11501
|
+
zIndex: "2147483000",
|
|
11109
11502
|
background: c.surface,
|
|
11110
11503
|
border: `1px solid ${c.border}`,
|
|
11111
11504
|
borderRadius: `${tokens.radius.md}px`,
|
|
@@ -11322,14 +11715,26 @@ function makeDualSlider(tokens, opts) {
|
|
|
11322
11715
|
}
|
|
11323
11716
|
};
|
|
11324
11717
|
}
|
|
11325
|
-
function positionPopover(pop, anchor
|
|
11718
|
+
function positionPopover(pop, anchor) {
|
|
11326
11719
|
const a = anchor.getBoundingClientRect();
|
|
11327
|
-
const
|
|
11328
|
-
|
|
11329
|
-
|
|
11720
|
+
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
11721
|
+
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
11722
|
+
const margin = 8;
|
|
11723
|
+
const gap = 4;
|
|
11330
11724
|
pop.style.width = `${a.width}px`;
|
|
11331
|
-
const
|
|
11332
|
-
pop.style.
|
|
11725
|
+
const left = Math.min(Math.max(margin, a.left), Math.max(margin, vw - a.width - margin));
|
|
11726
|
+
pop.style.left = `${left}px`;
|
|
11727
|
+
const spaceBelow = vh - a.bottom - margin;
|
|
11728
|
+
const spaceAbove = a.top - margin;
|
|
11729
|
+
if (spaceBelow >= 160 || spaceBelow >= spaceAbove) {
|
|
11730
|
+
pop.style.top = `${a.bottom + gap}px`;
|
|
11731
|
+
pop.style.bottom = "";
|
|
11732
|
+
pop.style.maxHeight = `${Math.max(80, spaceBelow - gap)}px`;
|
|
11733
|
+
} else {
|
|
11734
|
+
pop.style.top = "";
|
|
11735
|
+
pop.style.bottom = `${vh - a.top + gap}px`;
|
|
11736
|
+
pop.style.maxHeight = `${Math.max(80, spaceAbove - gap)}px`;
|
|
11737
|
+
}
|
|
11333
11738
|
}
|
|
11334
11739
|
function clamp7(v, lo, hi) {
|
|
11335
11740
|
return v < lo ? lo : v > hi ? hi : v;
|
|
@@ -11390,12 +11795,14 @@ function emptyNotice(host, message, color) {
|
|
|
11390
11795
|
}
|
|
11391
11796
|
|
|
11392
11797
|
// src/charts/slicers/dropdown.ts
|
|
11798
|
+
var openMenus = /* @__PURE__ */ new WeakMap();
|
|
11393
11799
|
function drawDropdown(surface, spec, tokens, size, context) {
|
|
11394
11800
|
const s = spec;
|
|
11395
11801
|
if (surface.headless) {
|
|
11396
11802
|
drawDropdownCanvas(surface, s, tokens, size, context);
|
|
11397
11803
|
return;
|
|
11398
11804
|
}
|
|
11805
|
+
openMenus.get(surface)?.();
|
|
11399
11806
|
const shell = mountSlicerShell(surface, tokens, size, {
|
|
11400
11807
|
title: s.title,
|
|
11401
11808
|
label: slicerLabel(s)
|
|
@@ -11429,16 +11836,34 @@ function drawDropdown(surface, spec, tokens, size, context) {
|
|
|
11429
11836
|
emit();
|
|
11430
11837
|
};
|
|
11431
11838
|
let pop = null;
|
|
11839
|
+
const reposition = () => {
|
|
11840
|
+
if (!pop) return;
|
|
11841
|
+
if (!trigger.isConnected) {
|
|
11842
|
+
closeMenu();
|
|
11843
|
+
return;
|
|
11844
|
+
}
|
|
11845
|
+
positionPopover(pop, trigger);
|
|
11846
|
+
};
|
|
11432
11847
|
const closeMenu = () => {
|
|
11433
11848
|
if (pop) {
|
|
11434
11849
|
pop.remove();
|
|
11435
11850
|
pop = null;
|
|
11436
11851
|
document.removeEventListener("pointerdown", onOutside, true);
|
|
11852
|
+
document.removeEventListener("keydown", onKey, true);
|
|
11853
|
+
window.removeEventListener("scroll", reposition, true);
|
|
11854
|
+
window.removeEventListener("resize", reposition);
|
|
11855
|
+
openMenus.delete(surface);
|
|
11437
11856
|
}
|
|
11438
11857
|
};
|
|
11439
11858
|
const onOutside = (e) => {
|
|
11440
11859
|
if (pop && !pop.contains(e.target) && !trigger.contains(e.target)) closeMenu();
|
|
11441
11860
|
};
|
|
11861
|
+
const onKey = (e) => {
|
|
11862
|
+
if (e.key === "Escape" && pop) {
|
|
11863
|
+
closeMenu();
|
|
11864
|
+
trigger.focus();
|
|
11865
|
+
}
|
|
11866
|
+
};
|
|
11442
11867
|
const openMenu = () => {
|
|
11443
11868
|
if (pop) {
|
|
11444
11869
|
closeMenu();
|
|
@@ -11469,9 +11894,13 @@ function drawDropdown(surface, spec, tokens, size, context) {
|
|
|
11469
11894
|
});
|
|
11470
11895
|
pop.appendChild(row);
|
|
11471
11896
|
}
|
|
11472
|
-
|
|
11473
|
-
positionPopover(pop, trigger
|
|
11897
|
+
document.body.appendChild(pop);
|
|
11898
|
+
positionPopover(pop, trigger);
|
|
11899
|
+
openMenus.set(surface, closeMenu);
|
|
11474
11900
|
document.addEventListener("pointerdown", onOutside, true);
|
|
11901
|
+
document.addEventListener("keydown", onKey, true);
|
|
11902
|
+
window.addEventListener("scroll", reposition, true);
|
|
11903
|
+
window.addEventListener("resize", reposition);
|
|
11475
11904
|
};
|
|
11476
11905
|
trigger.addEventListener("click", openMenu);
|
|
11477
11906
|
refreshChrome();
|
|
@@ -12131,10 +12560,12 @@ function tooltipEnabled(spec) {
|
|
|
12131
12560
|
return true;
|
|
12132
12561
|
}
|
|
12133
12562
|
function buildCartesianInteraction(model) {
|
|
12134
|
-
if (!tooltipEnabled(model.spec))
|
|
12135
|
-
|
|
12136
|
-
|
|
12137
|
-
|
|
12563
|
+
if (!tooltipEnabled(model.spec) || model.series.length === 0) {
|
|
12564
|
+
return model.legendHits?.length ? { region: model.plot, hitTest: () => null, legendHits: model.legendHits } : null;
|
|
12565
|
+
}
|
|
12566
|
+
const interaction = model.spec.type === "scatter" ? scatterInteraction(model) : indexInteraction(model);
|
|
12567
|
+
if (interaction) interaction.legendHits = model.legendHits;
|
|
12568
|
+
return interaction;
|
|
12138
12569
|
}
|
|
12139
12570
|
function inside(r, px, py, pad3 = 0) {
|
|
12140
12571
|
return px >= r.x - pad3 && px <= r.x + r.width + pad3 && py >= r.y - pad3 && py <= r.y + r.height + pad3;
|
|
@@ -12148,6 +12579,7 @@ function indexInteraction(model) {
|
|
|
12148
12579
|
const xFormat = model.spec.encoding.x?.format;
|
|
12149
12580
|
const yFormat = model.spec.encoding.y?.format;
|
|
12150
12581
|
const band = x.kind === "band";
|
|
12582
|
+
const horizontal = model.orientation === "horizontal";
|
|
12151
12583
|
const showDots = !model.stacked && !band;
|
|
12152
12584
|
const stopByKey = /* @__PURE__ */ new Map();
|
|
12153
12585
|
const lookups = [];
|
|
@@ -12182,7 +12614,7 @@ function indexInteraction(model) {
|
|
|
12182
12614
|
};
|
|
12183
12615
|
const hitTest = (px, py) => {
|
|
12184
12616
|
if (!inside(plot, px, py, 6)) return null;
|
|
12185
|
-
const best = nearestStop(px);
|
|
12617
|
+
const best = nearestStop(horizontal ? py : px);
|
|
12186
12618
|
const rows = [];
|
|
12187
12619
|
const dots = [];
|
|
12188
12620
|
let nearestDy = Infinity;
|
|
@@ -12210,15 +12642,19 @@ function indexInteraction(model) {
|
|
|
12210
12642
|
const anchorY = showDots && nearestIdx >= 0 ? dots[nearestIdx].py : py;
|
|
12211
12643
|
return {
|
|
12212
12644
|
key: best.key,
|
|
12213
|
-
anchorX: best.px,
|
|
12214
|
-
anchorY,
|
|
12645
|
+
anchorX: horizontal ? px : best.px,
|
|
12646
|
+
anchorY: horizontal ? best.px : anchorY,
|
|
12215
12647
|
content: { title: formatValue(best.value, xFormat), rows },
|
|
12216
12648
|
draw: (ctx) => {
|
|
12217
12649
|
ctx.save();
|
|
12218
12650
|
if (band) {
|
|
12219
12651
|
const half = x.bandwidth / 2;
|
|
12220
12652
|
ctx.fillStyle = withAlpha(model.tokens.color.text, 0.06);
|
|
12221
|
-
|
|
12653
|
+
if (horizontal) {
|
|
12654
|
+
ctx.fillRect(plot.x, best.px - half, plot.width, x.bandwidth);
|
|
12655
|
+
} else {
|
|
12656
|
+
ctx.fillRect(best.px - half, plot.y, x.bandwidth, plot.height);
|
|
12657
|
+
}
|
|
12222
12658
|
} else {
|
|
12223
12659
|
ctx.strokeStyle = withAlpha(model.tokens.color.text, 0.28);
|
|
12224
12660
|
ctx.lineWidth = 1;
|
|
@@ -12247,7 +12683,7 @@ function indexInteraction(model) {
|
|
|
12247
12683
|
};
|
|
12248
12684
|
const pick = (px, py) => {
|
|
12249
12685
|
if (!inside(plot, px, py, 6)) return null;
|
|
12250
|
-
const best = nearestStop(px);
|
|
12686
|
+
const best = nearestStop(horizontal ? py : px);
|
|
12251
12687
|
return { kind: "point", fields: [xField], tuples: [[best.value]] };
|
|
12252
12688
|
};
|
|
12253
12689
|
return { region: plot, hitTest, pick };
|
|
@@ -12285,17 +12721,35 @@ function scatterInteraction(model) {
|
|
|
12285
12721
|
}
|
|
12286
12722
|
}
|
|
12287
12723
|
if (points.length === 0) return null;
|
|
12724
|
+
const cell = HIT_RADIUS;
|
|
12725
|
+
const grid = /* @__PURE__ */ new Map();
|
|
12726
|
+
for (let i = 0; i < points.length; i++) {
|
|
12727
|
+
const col = Math.floor(points[i].px / cell);
|
|
12728
|
+
const row = Math.floor(points[i].py / cell);
|
|
12729
|
+
const key = `${col},${row}`;
|
|
12730
|
+
const bucket = grid.get(key);
|
|
12731
|
+
if (bucket) bucket.push(i);
|
|
12732
|
+
else grid.set(key, [i]);
|
|
12733
|
+
}
|
|
12288
12734
|
const nearest = (px, py) => {
|
|
12289
12735
|
if (!inside(plot, px, py, HIT_RADIUS)) return -1;
|
|
12290
12736
|
let best = -1;
|
|
12291
12737
|
let bestSq = HIT_RADIUS * HIT_RADIUS;
|
|
12292
|
-
|
|
12293
|
-
|
|
12294
|
-
|
|
12295
|
-
|
|
12296
|
-
|
|
12297
|
-
|
|
12298
|
-
|
|
12738
|
+
const col = Math.floor(px / cell);
|
|
12739
|
+
const row = Math.floor(py / cell);
|
|
12740
|
+
for (let dc = -1; dc <= 1; dc++) {
|
|
12741
|
+
for (let dr = -1; dr <= 1; dr++) {
|
|
12742
|
+
const bucket = grid.get(`${col + dc},${row + dr}`);
|
|
12743
|
+
if (!bucket) continue;
|
|
12744
|
+
for (const i of bucket) {
|
|
12745
|
+
const dx = points[i].px - px;
|
|
12746
|
+
const dy = points[i].py - py;
|
|
12747
|
+
const sq = dx * dx + dy * dy;
|
|
12748
|
+
if (sq < bestSq || sq === bestSq && i > best) {
|
|
12749
|
+
bestSq = sq;
|
|
12750
|
+
best = i;
|
|
12751
|
+
}
|
|
12752
|
+
}
|
|
12299
12753
|
}
|
|
12300
12754
|
}
|
|
12301
12755
|
return best;
|
|
@@ -12596,6 +13050,8 @@ function buildComboModel(spec, tokens, opts) {
|
|
|
12596
13050
|
series: [{ key: "", label: name, color, value: name, rows: data }],
|
|
12597
13051
|
seriesField: void 0,
|
|
12598
13052
|
stacked: false,
|
|
13053
|
+
orientation: "vertical",
|
|
13054
|
+
project: (c, v) => ({ x: c, y: v }),
|
|
12599
13055
|
xTicks,
|
|
12600
13056
|
yTicks: sideTicks,
|
|
12601
13057
|
colorOf: () => color,
|
|
@@ -12623,6 +13079,8 @@ function buildComboModel(spec, tokens, opts) {
|
|
|
12623
13079
|
series: [],
|
|
12624
13080
|
seriesField: void 0,
|
|
12625
13081
|
stacked: false,
|
|
13082
|
+
orientation: "vertical",
|
|
13083
|
+
project: (c, v) => ({ x: c, y: v }),
|
|
12626
13084
|
xTicks,
|
|
12627
13085
|
yTicks: leftTicks,
|
|
12628
13086
|
colorOf: () => "",
|
|
@@ -12860,6 +13318,8 @@ function buildHistogramModel(spec, tokens, opts) {
|
|
|
12860
13318
|
series: [],
|
|
12861
13319
|
seriesField: void 0,
|
|
12862
13320
|
stacked: false,
|
|
13321
|
+
orientation: "vertical",
|
|
13322
|
+
project: (c, v) => ({ x: c, y: v }),
|
|
12863
13323
|
xTicks,
|
|
12864
13324
|
yTicks,
|
|
12865
13325
|
colorOf: () => color,
|
|
@@ -13269,8 +13729,8 @@ function resolveValue2(ref, data) {
|
|
|
13269
13729
|
function valueField2(ref) {
|
|
13270
13730
|
return typeof ref === "number" ? null : ref.field;
|
|
13271
13731
|
}
|
|
13272
|
-
function titleText(
|
|
13273
|
-
return resolveTitle2(
|
|
13732
|
+
function titleText(title2) {
|
|
13733
|
+
return resolveTitle2(title2).text;
|
|
13274
13734
|
}
|
|
13275
13735
|
function withAlpha3(color, alpha) {
|
|
13276
13736
|
const rgba2 = parseColor(color);
|
|
@@ -13454,9 +13914,9 @@ function drawGauge(surface, spec, tokens, size, context) {
|
|
|
13454
13914
|
function clamp9(value, min, max) {
|
|
13455
13915
|
return Math.max(min, Math.min(max, value));
|
|
13456
13916
|
}
|
|
13457
|
-
function titleText2(
|
|
13458
|
-
if (typeof
|
|
13459
|
-
if (
|
|
13917
|
+
function titleText2(title2) {
|
|
13918
|
+
if (typeof title2 === "string") return title2;
|
|
13919
|
+
if (title2 && typeof title2 === "object") return title2.text;
|
|
13460
13920
|
return void 0;
|
|
13461
13921
|
}
|
|
13462
13922
|
function valueField3(ref) {
|
|
@@ -14063,6 +14523,8 @@ function buildWaterfallModel(spec, tokens, opts) {
|
|
|
14063
14523
|
series: [],
|
|
14064
14524
|
seriesField: void 0,
|
|
14065
14525
|
stacked: false,
|
|
14526
|
+
orientation: "vertical",
|
|
14527
|
+
project: (c, v) => ({ x: c, y: v }),
|
|
14066
14528
|
xTicks,
|
|
14067
14529
|
yTicks,
|
|
14068
14530
|
colorOf: () => incColor,
|
|
@@ -14324,6 +14786,8 @@ function buildSlopeModel(spec, tokens, opts) {
|
|
|
14324
14786
|
series: [],
|
|
14325
14787
|
seriesField: void 0,
|
|
14326
14788
|
stacked: false,
|
|
14789
|
+
orientation: "vertical",
|
|
14790
|
+
project: (c, v) => ({ x: c, y: v }),
|
|
14327
14791
|
xTicks,
|
|
14328
14792
|
yTicks,
|
|
14329
14793
|
colorOf: () => tokens.color.text,
|
|
@@ -14798,9 +15262,14 @@ function toPixel(model, axis, value) {
|
|
|
14798
15262
|
return px != null && Number.isFinite(px) ? px : void 0;
|
|
14799
15263
|
}
|
|
14800
15264
|
function pointPixels(model, ann) {
|
|
14801
|
-
const
|
|
14802
|
-
const
|
|
14803
|
-
|
|
15265
|
+
const cat = toPixel(model, "x", ann.x);
|
|
15266
|
+
const val = toPixel(model, "y", ann.y);
|
|
15267
|
+
if (cat === void 0 || val === void 0) return void 0;
|
|
15268
|
+
const { x, y } = model.project(cat, val);
|
|
15269
|
+
return { px: x, py: y };
|
|
15270
|
+
}
|
|
15271
|
+
function drawsHorizontalRule(model, axis) {
|
|
15272
|
+
return model.orientation === "horizontal" ? axis === "x" : axis === "y";
|
|
14804
15273
|
}
|
|
14805
15274
|
function anchorPixel(model, ann, axis, kind) {
|
|
14806
15275
|
if (kind === "line") return toPixel(model, axis, ann.value);
|
|
@@ -14849,6 +15318,7 @@ function drawAnnotations(surface, model) {
|
|
|
14849
15318
|
}
|
|
14850
15319
|
const axis = ann.axis === "x" ? "x" : "y";
|
|
14851
15320
|
const color = ann.color ?? tokens.color.textMuted;
|
|
15321
|
+
const ruleHorizontal = drawsHorizontalRule(model, axis);
|
|
14852
15322
|
if (kindOf(ann) === "line") {
|
|
14853
15323
|
const p = toPixel(model, axis, ann.value);
|
|
14854
15324
|
if (p === void 0) continue;
|
|
@@ -14857,7 +15327,7 @@ function drawAnnotations(surface, model) {
|
|
|
14857
15327
|
ctx.lineWidth = ann.strokeWidth ?? 1.5;
|
|
14858
15328
|
ctx.setLineDash(ann.strokeDash ?? DEFAULT_DASH);
|
|
14859
15329
|
ctx.beginPath();
|
|
14860
|
-
if (
|
|
15330
|
+
if (ruleHorizontal) {
|
|
14861
15331
|
const y = crisp(p);
|
|
14862
15332
|
ctx.moveTo(plot.x, y);
|
|
14863
15333
|
ctx.lineTo(x1, y);
|
|
@@ -14878,7 +15348,7 @@ function drawAnnotations(surface, model) {
|
|
|
14878
15348
|
ctx.save();
|
|
14879
15349
|
ctx.globalAlpha = ann.fillOpacity ?? DEFAULT_FILL_OPACITY;
|
|
14880
15350
|
ctx.fillStyle = color;
|
|
14881
|
-
if (
|
|
15351
|
+
if (ruleHorizontal) ctx.fillRect(plot.x, lo, plot.width, hi - lo);
|
|
14882
15352
|
else ctx.fillRect(lo, plot.y, hi - lo, plot.height);
|
|
14883
15353
|
ctx.restore();
|
|
14884
15354
|
ctx.save();
|
|
@@ -14887,7 +15357,7 @@ function drawAnnotations(surface, model) {
|
|
|
14887
15357
|
ctx.lineWidth = 1;
|
|
14888
15358
|
ctx.setLineDash(ann.strokeDash ?? [4, 4]);
|
|
14889
15359
|
ctx.beginPath();
|
|
14890
|
-
if (
|
|
15360
|
+
if (ruleHorizontal) {
|
|
14891
15361
|
ctx.moveTo(plot.x, crisp(lo));
|
|
14892
15362
|
ctx.lineTo(x1, crisp(lo));
|
|
14893
15363
|
ctx.moveTo(plot.x, crisp(hi));
|
|
@@ -14903,10 +15373,10 @@ function drawAnnotations(surface, model) {
|
|
|
14903
15373
|
}
|
|
14904
15374
|
ctx.restore();
|
|
14905
15375
|
}
|
|
14906
|
-
function labelBox(model,
|
|
15376
|
+
function labelBox(model, horizontalRule, pos, p) {
|
|
14907
15377
|
const { plot } = model;
|
|
14908
15378
|
const inset = 6;
|
|
14909
|
-
if (
|
|
15379
|
+
if (horizontalRule) {
|
|
14910
15380
|
const top = p - 3;
|
|
14911
15381
|
const transform3 = "translateY(-100%)";
|
|
14912
15382
|
if (pos === "start") return { left: plot.x + inset, top, width: plot.width - inset, align: "left", transform: transform3 };
|
|
@@ -14942,7 +15412,7 @@ function drawAnnotationLabels(surface, model) {
|
|
|
14942
15412
|
const axis = ann.axis === "x" ? "x" : "y";
|
|
14943
15413
|
const p = anchorPixel(model, ann, axis, kindOf(ann));
|
|
14944
15414
|
if (p === void 0) continue;
|
|
14945
|
-
box = labelBox(model, axis, ann.labelPosition ?? "end", p);
|
|
15415
|
+
box = labelBox(model, drawsHorizontalRule(model, axis), ann.labelPosition ?? "end", p);
|
|
14946
15416
|
}
|
|
14947
15417
|
const color = ann.color ?? tokens.color.text;
|
|
14948
15418
|
const pill = { background: withAlpha4(tokens.color.background, 0.72), radius: 3, padX: 3, padY: 1 };
|
|
@@ -15285,6 +15755,13 @@ var Tooltip = class {
|
|
|
15285
15755
|
};
|
|
15286
15756
|
|
|
15287
15757
|
// src/interaction/controller.ts
|
|
15758
|
+
function resolveLegendHit(model, px, py) {
|
|
15759
|
+
for (const hit of model.legendHits ?? []) {
|
|
15760
|
+
const r = hit.rect;
|
|
15761
|
+
if (px >= r.x && px <= r.x + r.width && py >= r.y && py <= r.y + r.height) return hit;
|
|
15762
|
+
}
|
|
15763
|
+
return null;
|
|
15764
|
+
}
|
|
15288
15765
|
var InteractionController = class {
|
|
15289
15766
|
constructor(surface, tokens) {
|
|
15290
15767
|
__publicField(this, "surface", surface);
|
|
@@ -15313,9 +15790,17 @@ var InteractionController = class {
|
|
|
15313
15790
|
__publicField(this, "onClick", (e) => {
|
|
15314
15791
|
const cfg = this.selectCfg;
|
|
15315
15792
|
const model = this.model;
|
|
15316
|
-
if (!cfg || !model
|
|
15793
|
+
if (!cfg || !model) return;
|
|
15317
15794
|
const rect = this.surface.root.getBoundingClientRect();
|
|
15318
|
-
|
|
15795
|
+
const px = e.clientX - rect.left;
|
|
15796
|
+
const py = e.clientY - rect.top;
|
|
15797
|
+
const onLegendToggle = cfg.onLegendToggle;
|
|
15798
|
+
const legendHit = onLegendToggle ? resolveLegendHit(model, px, py) : null;
|
|
15799
|
+
if (legendHit && onLegendToggle) {
|
|
15800
|
+
onLegendToggle(legendHit, e);
|
|
15801
|
+
return;
|
|
15802
|
+
}
|
|
15803
|
+
if (model.pick && cfg.onPick) cfg.onPick(model.pick(px, py));
|
|
15319
15804
|
});
|
|
15320
15805
|
__publicField(this, "flush", () => {
|
|
15321
15806
|
this.raf = 0;
|
|
@@ -15343,7 +15828,7 @@ var InteractionController = class {
|
|
|
15343
15828
|
this.tokens = tokens;
|
|
15344
15829
|
this.tooltip.setTokens(tokens);
|
|
15345
15830
|
this.activeKey = null;
|
|
15346
|
-
this.pickable = Boolean(model?.pick);
|
|
15831
|
+
this.pickable = Boolean(model?.pick || model?.legendHits?.length);
|
|
15347
15832
|
if (!model) this.tooltip.hide();
|
|
15348
15833
|
}
|
|
15349
15834
|
/** Wire click/tap selection for this chart (or `null` to disable it). */
|
|
@@ -15611,6 +16096,14 @@ function buildRenderReport(input) {
|
|
|
15611
16096
|
const colorCount = model ? new Set(seriesColors).size || 1 : seriesCount;
|
|
15612
16097
|
const markCount = model ? model.series.reduce((n, s) => n + s.rows.length, 0) : data.length;
|
|
15613
16098
|
const plot = model?.plot;
|
|
16099
|
+
if (type === "scatter" && model?.markStats && model.markStats.drawn < model.markStats.original) {
|
|
16100
|
+
add({
|
|
16101
|
+
code: "scatter-quantized",
|
|
16102
|
+
severity: "info",
|
|
16103
|
+
message: `Dense scatter rendering was quantized from ${model.markStats.original} to ${model.markStats.drawn} drawn points to keep interaction smooth.`,
|
|
16104
|
+
details: { drawn: model.markStats.drawn, original: model.markStats.original }
|
|
16105
|
+
});
|
|
16106
|
+
}
|
|
15614
16107
|
const hasLiteralValue = VALUE_CHART_TYPES.has(type) && typeof spec.value === "number";
|
|
15615
16108
|
if (data.length === 0 && !hasLiteralValue) {
|
|
15616
16109
|
add({
|
|
@@ -15634,9 +16127,9 @@ function buildRenderReport(input) {
|
|
|
15634
16127
|
}
|
|
15635
16128
|
if (model) {
|
|
15636
16129
|
checkDegenerateAxis(model, add);
|
|
15637
|
-
checkClippedMarks(model, add);
|
|
15638
|
-
checkAxisLabelOverlap(model, tokens, add);
|
|
15639
|
-
checkLegendOverflow(model, add);
|
|
16130
|
+
checkClippedMarks(model, spec, add);
|
|
16131
|
+
checkAxisLabelOverlap(model, spec, tokens, add);
|
|
16132
|
+
checkLegendOverflow(model, spec, add);
|
|
15640
16133
|
}
|
|
15641
16134
|
if (bg) {
|
|
15642
16135
|
const specColor = spec.color;
|
|
@@ -15675,7 +16168,8 @@ function buildRenderReport(input) {
|
|
|
15675
16168
|
code: "too-many-colors",
|
|
15676
16169
|
severity: "info",
|
|
15677
16170
|
message: `${colorCount} series share one color scale \u2014 beyond ~${MAX_DISTINGUISHABLE_SERIES} the colors repeat or blur together. Consider faceting, filtering, or grouping the long tail.`,
|
|
15678
|
-
details: { colorCount, max: MAX_DISTINGUISHABLE_SERIES }
|
|
16171
|
+
details: { colorCount, max: MAX_DISTINGUISHABLE_SERIES },
|
|
16172
|
+
...tooManyColorsRepair(spec)
|
|
15679
16173
|
});
|
|
15680
16174
|
}
|
|
15681
16175
|
diagnostics.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
|
@@ -15683,6 +16177,11 @@ function buildRenderReport(input) {
|
|
|
15683
16177
|
const summary = summarize({ ...spec, data }) || void 0;
|
|
15684
16178
|
return { type, size, plot, markCount, seriesCount, colorCount, ok, diagnostics, summary };
|
|
15685
16179
|
}
|
|
16180
|
+
function repairReport(spec, report) {
|
|
16181
|
+
const applied = report.diagnostics.flatMap((d) => d.fix ?? []);
|
|
16182
|
+
if (applied.length === 0) return { spec, applied: [] };
|
|
16183
|
+
return { spec: applyPatch(spec, applied), applied };
|
|
16184
|
+
}
|
|
15686
16185
|
function seriesCountOf(spec, data) {
|
|
15687
16186
|
const enc = spec.encoding;
|
|
15688
16187
|
const field = enc?.series?.field ?? enc?.color?.field;
|
|
@@ -15714,7 +16213,7 @@ function checkDegenerateAxis(model, add) {
|
|
|
15714
16213
|
});
|
|
15715
16214
|
}
|
|
15716
16215
|
}
|
|
15717
|
-
function checkClippedMarks(model, add) {
|
|
16216
|
+
function checkClippedMarks(model, spec, add) {
|
|
15718
16217
|
const field = model.y.field;
|
|
15719
16218
|
if (!field) return;
|
|
15720
16219
|
const read = accessor(field);
|
|
@@ -15734,20 +16233,25 @@ function checkClippedMarks(model, add) {
|
|
|
15734
16233
|
const pHi = model.y.pixel(hi);
|
|
15735
16234
|
const pLo = model.y.pixel(lo);
|
|
15736
16235
|
const EPS = 0.75;
|
|
15737
|
-
|
|
16236
|
+
const clipped = model.orientation === "horizontal" ? pLo < model.plot.x - EPS || pHi > model.plot.x + model.plot.width + EPS : pHi < top - EPS || pLo > bottom + EPS;
|
|
16237
|
+
if (clipped) {
|
|
15738
16238
|
add({
|
|
15739
16239
|
code: "marks-clipped",
|
|
15740
16240
|
severity: "warning",
|
|
15741
16241
|
axis: "y",
|
|
15742
16242
|
message: `Some data falls outside the y-axis range [${round(Math.min(lo, hi))}, ${round(Math.max(lo, hi))}] and is clipped at the plot edge \u2014 widen the y domain or drop the manual scale.`,
|
|
15743
|
-
details: { dataMin: round(lo), dataMax: round(hi) }
|
|
16243
|
+
details: { dataMin: round(lo), dataMax: round(hi) },
|
|
16244
|
+
...manualYDomainFix(spec) ?? {
|
|
16245
|
+
hint: "Drop the manual y scale domain or set a y scale domain/zero option that includes every data value."
|
|
16246
|
+
}
|
|
15744
16247
|
});
|
|
15745
16248
|
}
|
|
15746
16249
|
}
|
|
15747
|
-
function checkAxisLabelOverlap(model, tokens, add) {
|
|
16250
|
+
function checkAxisLabelOverlap(model, spec, tokens, add) {
|
|
15748
16251
|
const ticks2 = model.xTicks;
|
|
15749
16252
|
if (ticks2.length < 2) return;
|
|
15750
|
-
|
|
16253
|
+
const horizontal = model.orientation === "horizontal";
|
|
16254
|
+
if (!horizontal && model.frame.xLabelAngle > 0) return;
|
|
15751
16255
|
const font = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
|
|
15752
16256
|
const GAP3 = 4;
|
|
15753
16257
|
let collisions = 0;
|
|
@@ -15755,7 +16259,7 @@ function checkAxisLabelOverlap(model, tokens, add) {
|
|
|
15755
16259
|
const prev = ticks2[i - 1];
|
|
15756
16260
|
const cur = ticks2[i];
|
|
15757
16261
|
const spacing = Math.abs(cur.pos - prev.pos);
|
|
15758
|
-
const half = (measureText(prev.label, font).width + measureText(cur.label, font).width) / 2;
|
|
16262
|
+
const half = horizontal ? tokens.font.size.small : (measureText(prev.label, font).width + measureText(cur.label, font).width) / 2;
|
|
15759
16263
|
if (half + GAP3 > spacing) collisions++;
|
|
15760
16264
|
}
|
|
15761
16265
|
if (collisions > 0) {
|
|
@@ -15764,11 +16268,14 @@ function checkAxisLabelOverlap(model, tokens, add) {
|
|
|
15764
16268
|
severity: "warning",
|
|
15765
16269
|
axis: "x",
|
|
15766
16270
|
message: `${collisions} adjacent x-axis label${collisions === 1 ? "" : "s"} overlap \u2014 there isn't room to show all ${ticks2.length} categories. Reduce categories, widen the chart, or aggregate the long tail.`,
|
|
15767
|
-
details: { collisions, ticks: ticks2.length }
|
|
16271
|
+
details: { collisions, ticks: ticks2.length },
|
|
16272
|
+
...xLabelAngleFix(spec) ?? {
|
|
16273
|
+
hint: "Rotate x-axis labels, facet the chart, widen it, or aggregate the long tail."
|
|
16274
|
+
}
|
|
15768
16275
|
});
|
|
15769
16276
|
}
|
|
15770
16277
|
}
|
|
15771
|
-
function checkLegendOverflow(model, add) {
|
|
16278
|
+
function checkLegendOverflow(model, spec, add) {
|
|
15772
16279
|
const shown = model.frame.legendItems?.length;
|
|
15773
16280
|
if (shown === void 0) return;
|
|
15774
16281
|
const total = model.series.length;
|
|
@@ -15777,10 +16284,52 @@ function checkLegendOverflow(model, add) {
|
|
|
15777
16284
|
code: "legend-overflow",
|
|
15778
16285
|
severity: "warning",
|
|
15779
16286
|
message: `The legend shows only ${shown} of ${total} series \u2014 the rest were clipped to fit. Move the legend to the top/bottom or give the chart more height.`,
|
|
15780
|
-
details: { shown, total }
|
|
16287
|
+
details: { shown, total },
|
|
16288
|
+
...legendPositionFix(spec) ?? {
|
|
16289
|
+
hint: "Move the legend to top/bottom, increase chart height, filter series, or facet the chart."
|
|
16290
|
+
}
|
|
15781
16291
|
});
|
|
15782
16292
|
}
|
|
15783
16293
|
}
|
|
16294
|
+
function isRecord(v) {
|
|
16295
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
16296
|
+
}
|
|
16297
|
+
function xLabelAngleFix(spec) {
|
|
16298
|
+
const axes = spec.axes;
|
|
16299
|
+
if (!isRecord(axes)) return void 0;
|
|
16300
|
+
const x = axes.x;
|
|
16301
|
+
if (x === void 0) return { fix: [{ op: "add", path: "/axes/x", value: { labelAngle: 45 } }] };
|
|
16302
|
+
if (!isRecord(x)) return void 0;
|
|
16303
|
+
const op = Object.prototype.hasOwnProperty.call(x, "labelAngle") ? "replace" : "add";
|
|
16304
|
+
return { fix: [{ op, path: "/axes/x/labelAngle", value: 45 }] };
|
|
16305
|
+
}
|
|
16306
|
+
function legendPositionFix(spec) {
|
|
16307
|
+
const legend = spec.legend;
|
|
16308
|
+
if (legend === false) return void 0;
|
|
16309
|
+
if (legend === void 0 || legend === true) {
|
|
16310
|
+
return { fix: [{ op: legend === void 0 ? "add" : "replace", path: "/legend", value: { position: "bottom" } }] };
|
|
16311
|
+
}
|
|
16312
|
+
if (!isRecord(legend)) return void 0;
|
|
16313
|
+
const current = legend.position;
|
|
16314
|
+
if (current === "bottom" || current === "top") return void 0;
|
|
16315
|
+
const op = Object.prototype.hasOwnProperty.call(legend, "position") ? "replace" : "add";
|
|
16316
|
+
return { fix: [{ op, path: "/legend/position", value: "bottom" }] };
|
|
16317
|
+
}
|
|
16318
|
+
function tooManyColorsRepair(spec) {
|
|
16319
|
+
const palette = spec.palette;
|
|
16320
|
+
const hint = "Switch to the colorblind palette, facet the chart, filter series, or group the long tail.";
|
|
16321
|
+
if (Array.isArray(palette) || palette === "colorblind") return { hint };
|
|
16322
|
+
return { fix: [{ op: palette === void 0 ? "add" : "replace", path: "/palette", value: "colorblind" }], hint };
|
|
16323
|
+
}
|
|
16324
|
+
function manualYDomainFix(spec) {
|
|
16325
|
+
const enc = spec.encoding;
|
|
16326
|
+
if (!isRecord(enc)) return void 0;
|
|
16327
|
+
const y = enc.y;
|
|
16328
|
+
if (!isRecord(y)) return void 0;
|
|
16329
|
+
const scale = y.scale;
|
|
16330
|
+
if (!isRecord(scale) || !Object.prototype.hasOwnProperty.call(scale, "domain")) return void 0;
|
|
16331
|
+
return { fix: [{ op: "remove", path: "/encoding/y/scale/domain" }] };
|
|
16332
|
+
}
|
|
15784
16333
|
|
|
15785
16334
|
// src/runtime/facet.ts
|
|
15786
16335
|
var FACETABLE_TYPES2 = /* @__PURE__ */ new Set(["line", "area", "bar", "scatter"]);
|
|
@@ -15816,9 +16365,9 @@ function autoColumns(n) {
|
|
|
15816
16365
|
function legendSymbol(type) {
|
|
15817
16366
|
return type === "scatter" ? "circle" : type === "line" ? "line" : "square";
|
|
15818
16367
|
}
|
|
15819
|
-
function titleInput3(
|
|
15820
|
-
if (!
|
|
15821
|
-
return typeof
|
|
16368
|
+
function titleInput3(title2) {
|
|
16369
|
+
if (!title2) return void 0;
|
|
16370
|
+
return typeof title2 === "string" ? { text: title2 } : title2;
|
|
15822
16371
|
}
|
|
15823
16372
|
function panelSpec(spec, data, value) {
|
|
15824
16373
|
const base = spec;
|
|
@@ -15989,6 +16538,60 @@ var CANVAS_MARK_TYPES = /* @__PURE__ */ new Set([
|
|
|
15989
16538
|
"choropleth"
|
|
15990
16539
|
]);
|
|
15991
16540
|
var EMPTY_DATA = [];
|
|
16541
|
+
function legendConfigFor(model) {
|
|
16542
|
+
const legend = model.spec.legend;
|
|
16543
|
+
if (!(typeof legend === "object" && legend.interactive === true)) return null;
|
|
16544
|
+
const field = model.seriesField;
|
|
16545
|
+
if (!field || !model.legendHits?.length) return null;
|
|
16546
|
+
return { param: legend.param ?? field, field };
|
|
16547
|
+
}
|
|
16548
|
+
function visibleLegendKeys(value, field) {
|
|
16549
|
+
if (!value || value.kind !== "set" || value.field !== field) return null;
|
|
16550
|
+
return new Set(value.values.map((v) => toKey(v)));
|
|
16551
|
+
}
|
|
16552
|
+
function applyLegendVisibility(model, store) {
|
|
16553
|
+
const cfg = legendConfigFor(model);
|
|
16554
|
+
if (!cfg) return null;
|
|
16555
|
+
const visible = visibleLegendKeys(store.get(cfg.param), cfg.field);
|
|
16556
|
+
if (!visible) return cfg;
|
|
16557
|
+
model.series = model.series.filter((s) => visible.has(toKey(s.value)));
|
|
16558
|
+
return cfg;
|
|
16559
|
+
}
|
|
16560
|
+
function publishLegendToggle(store, cfg, allHits, hit, isolate) {
|
|
16561
|
+
const all = allHits.map((h) => h.value);
|
|
16562
|
+
const allKeys = all.map((v) => toKey(v));
|
|
16563
|
+
const hitKey = toKey(hit.value);
|
|
16564
|
+
const current = visibleLegendKeys(store.get(cfg.param), cfg.field);
|
|
16565
|
+
const visible = current ? new Set(current) : new Set(allKeys);
|
|
16566
|
+
if (isolate) {
|
|
16567
|
+
if (visible.size === 1 && visible.has(hitKey)) {
|
|
16568
|
+
store.set(cfg.param, null);
|
|
16569
|
+
return;
|
|
16570
|
+
}
|
|
16571
|
+
visible.clear();
|
|
16572
|
+
visible.add(hitKey);
|
|
16573
|
+
} else if (visible.has(hitKey)) {
|
|
16574
|
+
visible.delete(hitKey);
|
|
16575
|
+
} else {
|
|
16576
|
+
visible.add(hitKey);
|
|
16577
|
+
}
|
|
16578
|
+
if (visible.size === 0 || visible.size === allKeys.length) {
|
|
16579
|
+
store.set(cfg.param, null);
|
|
16580
|
+
return;
|
|
16581
|
+
}
|
|
16582
|
+
store.set(cfg.param, {
|
|
16583
|
+
kind: "set",
|
|
16584
|
+
field: cfg.field,
|
|
16585
|
+
values: all.filter((v) => visible.has(toKey(v)))
|
|
16586
|
+
});
|
|
16587
|
+
}
|
|
16588
|
+
function specLegendParam(spec) {
|
|
16589
|
+
const legend = spec.legend;
|
|
16590
|
+
if (!(typeof legend === "object" && legend.interactive === true)) return null;
|
|
16591
|
+
if (legend.param) return legend.param;
|
|
16592
|
+
const enc = spec.encoding;
|
|
16593
|
+
return enc?.series?.field ?? enc?.color?.field ?? null;
|
|
16594
|
+
}
|
|
15992
16595
|
function resolveContainer(target) {
|
|
15993
16596
|
if (typeof target === "string") {
|
|
15994
16597
|
const el = document.querySelector(target);
|
|
@@ -16084,6 +16687,7 @@ function render(target, spec, options) {
|
|
|
16084
16687
|
let interactionModel = null;
|
|
16085
16688
|
let reportModel;
|
|
16086
16689
|
let facetRpt;
|
|
16690
|
+
let legendSelect = null;
|
|
16087
16691
|
const type = currentSpec.type;
|
|
16088
16692
|
const facetLayout = isFaceted(effectiveSpec) ? buildFacetModels(effectiveSpec, tokens, size) : null;
|
|
16089
16693
|
if (facetLayout) {
|
|
@@ -16092,6 +16696,8 @@ function render(target, spec, options) {
|
|
|
16092
16696
|
} else if (CARTESIAN_TYPES.has(type)) {
|
|
16093
16697
|
const renderer = cartesianRenderers[type];
|
|
16094
16698
|
const model = buildCartesianModel(effectiveSpec, tokens, size);
|
|
16699
|
+
const legendCfg = applyLegendVisibility(model, store);
|
|
16700
|
+
if (legendCfg && model.legendHits?.length) legendSelect = { cfg: legendCfg, hits: model.legendHits };
|
|
16095
16701
|
model.emphasis = emphasis;
|
|
16096
16702
|
reportModel = model;
|
|
16097
16703
|
if (!anim.enabled) {
|
|
@@ -16140,9 +16746,14 @@ function render(target, spec, options) {
|
|
|
16140
16746
|
}
|
|
16141
16747
|
if (!interaction) interaction = new InteractionController(surface, tokens);
|
|
16142
16748
|
interaction.setModel(interactionModel, tokens);
|
|
16143
|
-
if (ownParam && interactionModel?.pick) {
|
|
16144
|
-
const cfg = { store, param: ownParam.name, def: ownParam.select };
|
|
16145
|
-
interaction.setSelect({
|
|
16749
|
+
if (ownParam && interactionModel?.pick || legendSelect && interactionModel?.legendHits?.length) {
|
|
16750
|
+
const cfg = ownParam ? { store, param: ownParam.name, def: ownParam.select } : null;
|
|
16751
|
+
interaction.setSelect({
|
|
16752
|
+
onPick: cfg && interactionModel?.pick ? (value) => applyPick(cfg, value) : void 0,
|
|
16753
|
+
onLegendToggle: legendSelect ? (hit, event) => {
|
|
16754
|
+
publishLegendToggle(store, legendSelect.cfg, legendSelect.hits, hit, event.shiftKey || event.altKey);
|
|
16755
|
+
} : void 0
|
|
16756
|
+
});
|
|
16146
16757
|
} else {
|
|
16147
16758
|
interaction.setSelect(null);
|
|
16148
16759
|
}
|
|
@@ -16199,7 +16810,7 @@ function render(target, spec, options) {
|
|
|
16199
16810
|
const unsubscribe = store.subscribe((name, value) => {
|
|
16200
16811
|
for (const listener of [...hostListeners]) listener(name, value);
|
|
16201
16812
|
if (destroyed) return;
|
|
16202
|
-
if (dependentParams(currentSpec.highlight, currentSpec.filter).has(name)) {
|
|
16813
|
+
if (dependentParams(currentSpec.highlight, currentSpec.filter).has(name) || specLegendParam(currentSpec) === name) {
|
|
16203
16814
|
draw(false);
|
|
16204
16815
|
}
|
|
16205
16816
|
});
|
|
@@ -16380,6 +16991,14 @@ function isSlicerType(type) {
|
|
|
16380
16991
|
function fieldOf(channel) {
|
|
16381
16992
|
return channel && typeof channel.field === "string" ? channel.field : void 0;
|
|
16382
16993
|
}
|
|
16994
|
+
function legendSource(spec) {
|
|
16995
|
+
const legend = spec.legend;
|
|
16996
|
+
if (!(typeof legend === "object" && legend.interactive === true)) return null;
|
|
16997
|
+
const enc = spec.encoding;
|
|
16998
|
+
const field = fieldOf(enc?.series) ?? fieldOf(enc?.color);
|
|
16999
|
+
if (!field) return null;
|
|
17000
|
+
return { param: legend.param ?? field, field };
|
|
17001
|
+
}
|
|
16383
17002
|
function specFields(spec) {
|
|
16384
17003
|
const out = /* @__PURE__ */ new Set();
|
|
16385
17004
|
if (isSlicerType(spec.type)) {
|
|
@@ -16468,9 +17087,14 @@ function wireViews(views, interactions) {
|
|
|
16468
17087
|
});
|
|
16469
17088
|
} else if (PICKABLE.has(type)) {
|
|
16470
17089
|
const fields = keyFields(v.spec);
|
|
16471
|
-
if (fields.length
|
|
16472
|
-
|
|
16473
|
-
|
|
17090
|
+
if (fields.length > 0) {
|
|
17091
|
+
const param = autoParamName(v);
|
|
17092
|
+
sources.push({ view: i, param, fields, as: "highlight", slicer: false });
|
|
17093
|
+
}
|
|
17094
|
+
const legend = legendSource(v.spec);
|
|
17095
|
+
if (legend) {
|
|
17096
|
+
sources.push({ view: i, param: legend.param, fields: [legend.field], as: "filter", slicer: true });
|
|
17097
|
+
}
|
|
16474
17098
|
}
|
|
16475
17099
|
});
|
|
16476
17100
|
const explicit = Array.isArray(interactions);
|
|
@@ -16536,6 +17160,14 @@ var DEFAULT_BREAKPOINTS = [
|
|
|
16536
17160
|
{ maxWidth: 960, cols: 6 }
|
|
16537
17161
|
];
|
|
16538
17162
|
var STRIP_SLICERS = /* @__PURE__ */ new Set(["dropdown", "search", "dateRange", "range"]);
|
|
17163
|
+
function stripCellHeight(view, rowHeight) {
|
|
17164
|
+
const titled = !!(view.title || view.subtitle);
|
|
17165
|
+
const titlePad = titled ? 30 : 0;
|
|
17166
|
+
const base = Math.max(96, rowHeight + 8);
|
|
17167
|
+
if (view.spec.type === "dateRange") return Math.max(base, 150 + titlePad);
|
|
17168
|
+
if (view.spec.type === "range") return Math.max(base, 132 + titlePad);
|
|
17169
|
+
return Math.max(base, 96 + titlePad);
|
|
17170
|
+
}
|
|
16539
17171
|
var clamp10 = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
16540
17172
|
function defaultSpan(spec, cols) {
|
|
16541
17173
|
const t = spec.type;
|
|
@@ -16799,14 +17431,14 @@ function renderDashboard(target, spec, options) {
|
|
|
16799
17431
|
gap: "2px"
|
|
16800
17432
|
});
|
|
16801
17433
|
if (view.title) {
|
|
16802
|
-
const
|
|
16803
|
-
|
|
16804
|
-
Object.assign(
|
|
17434
|
+
const title2 = document.createElement("div");
|
|
17435
|
+
title2.textContent = view.title;
|
|
17436
|
+
Object.assign(title2.style, {
|
|
16805
17437
|
font: `${tokens.font.weight.bold} ${tokens.font.size.base}px ${tokens.font.family}`,
|
|
16806
17438
|
color: tokens.color.text,
|
|
16807
17439
|
letterSpacing: "-0.01em"
|
|
16808
17440
|
});
|
|
16809
|
-
header.appendChild(
|
|
17441
|
+
header.appendChild(title2);
|
|
16810
17442
|
}
|
|
16811
17443
|
if (view.subtitle) {
|
|
16812
17444
|
const subtitle = document.createElement("div");
|
|
@@ -16838,6 +17470,7 @@ function renderDashboard(target, spec, options) {
|
|
|
16838
17470
|
Object.assign(strip.style, {
|
|
16839
17471
|
display: "flex",
|
|
16840
17472
|
flexWrap: "wrap",
|
|
17473
|
+
alignItems: "flex-start",
|
|
16841
17474
|
gap: `${layout2.gap}px`
|
|
16842
17475
|
});
|
|
16843
17476
|
for (const view of stripViews) {
|
|
@@ -16845,7 +17478,7 @@ function renderDashboard(target, spec, options) {
|
|
|
16845
17478
|
Object.assign(cell.style, {
|
|
16846
17479
|
flex: "1 1 220px",
|
|
16847
17480
|
minWidth: "200px",
|
|
16848
|
-
height: `${
|
|
17481
|
+
height: `${stripCellHeight(view, layout2.rowHeight)}px`
|
|
16849
17482
|
});
|
|
16850
17483
|
strip.appendChild(cell);
|
|
16851
17484
|
deferredMounts.push({ el: host, view, mounted: false });
|
|
@@ -16885,10 +17518,10 @@ function renderDashboard(target, spec, options) {
|
|
|
16885
17518
|
const titles = document.createElement("span");
|
|
16886
17519
|
Object.assign(titles.style, { display: "flex", flexDirection: "column", gap: "2px" });
|
|
16887
17520
|
if (plan.section?.title) {
|
|
16888
|
-
const
|
|
16889
|
-
|
|
16890
|
-
Object.assign(
|
|
16891
|
-
titles.appendChild(
|
|
17521
|
+
const title2 = document.createElement("span");
|
|
17522
|
+
title2.textContent = plan.section.title;
|
|
17523
|
+
Object.assign(title2.style, { fontWeight: String(tokens.font.weight.bold), fontSize: `${tokens.font.size.large}px` });
|
|
17524
|
+
titles.appendChild(title2);
|
|
16892
17525
|
}
|
|
16893
17526
|
if (plan.section?.subtitle) {
|
|
16894
17527
|
const subtitle = document.createElement("span");
|
|
@@ -17231,6 +17864,6 @@ function renderToContext(target, spec) {
|
|
|
17231
17864
|
// src/index.ts
|
|
17232
17865
|
var VERSION = "0.1.0";
|
|
17233
17866
|
|
|
17234
|
-
export { CARTESIAN_TYPES, CHART_TYPES, CanvasLayer, DEFAULT_ENTRANCE_DURATION, DEFAULT_ROUGH_STYLE, DEFAULT_UPDATE_DURATION, DIM_ALPHA, ExpressionError, FACETABLE_TYPES2 as FACETABLE_TYPES, FUNCTION_NAMES, InteractionController, RoughPen, SKETCH_FONT_FAMILY, SKETCH_FONT_NAME, SLICER_TYPES, SR_ONLY_STYLE, Surface, TICK_SIZE, TIME_UNITS, TRANSFORM_KINDS, Tooltip, VERSION, aggregateValues, analyzeChart, animate, applyA11y, applyAggregate, applyBin, applyCalculate, applyFilter, applyFold, applyPatch, applyPick, applyTimeUnit, applyTransform, applyTransforms, arc, area, assertValidSpec, autoInsightAnnotations, backOut, bandScale, bounceOut, buildCartesianInteraction, buildCartesianModel, buildComboModel, buildDataTableFallback, buildFacetModels, buildHistogramModel, buildRenderReport, cartesianInteractionBuilders, cartesianRenderers, categorical, chartTitleText, chartTypeLabel, checkExpression, clamp012 as clamp01, clamp255, compileExpression, compilePredicate, computeBackingSize, computeBins, computeFrame, computeSeriesInsights, contrastRatio, createDiv, createRoughPen, createSelectionStore, cubicIn, cubicInOut, cubicOut, curveCatmullRom, curveLinear, curveMonotoneX, curveStep, curveStepAfter, curveStepBefore, customRenderers, darkTheme, decimate, defaultSpan, dependentParams, diverging, divergingColorScale, divergingSchemes, drawAnnotationLabels, drawAnnotations, drawAxesUnderlay, drawFacet, drawLegend, drawOverlay, drawTitle, drawTrendlineLabels, drawTrendlines, easings, elasticOut, ellipsize, ensureSketchFont, expoInOut, expoOut, facetReport, fillParentStyle, filterRows, fontString, formatDate, formatNumber2 as formatNumber, formatValue, getDevicePixelRatio, groupBy, hashString, inferFieldType, inferFieldTypes, inferValueType, inheritInto, interpolateArray, interpolateNumber, interpolateNumberArray, interpolateObject, interpolateOklab, interpolateRgb, isBrowser, isEmptyValue, isFaceted, isParamClause, isSlicerType, keyFields, legendSwatchWidth, lightTheme, line, linear, linearRegression, linearScale, linearToSrgb, lintSpec, literalToValue, logScale, lttbRun, makeMatcher, matchesValue, measureText, monotoneXTangents, mulberry32, niceDomain, oklabToOklch, oklabToRgb, oklchToOklab, ordinalColorScale, overlayTextToCanvasCmd, paintCanvasText, paintLegendSwatch, parseColor, parseNumberFormat, pivot, placeViews, pointScale, polygonHachureLines, prefersReducedMotion, prettyDate, quadIn, quadInOut, quadOut, rampFromStops, readableTextColor, relativeLuminance, render, renderDashboard, renderToContext, repairSpec, resolveCurve, resolveDashboardLayout, resolveDashboardSections, resolveEmphasis, resolveEntrance, resolveFilterValues, resolveInsightOptions, resolveSketch, resolveTheme, resolveUpdate, rgbToOklab, rgbaToCss, rotatePoint, roundedRect, sampleArc, sequential, sequentialColorScale, sequentialSchemes, setMeasureContext, setStyle, sinInOut, slicerParamName, smartDate, specFields, srgbToLinear, summarize, summarizeChart, themes, tickIncrement, tickStep, ticks, timeScale, timeTickFormat, timeTicks, toHex, toPointer, tooltipEnabled, truncateTo, validateDashboard, validateSpec, wireViews, withAlpha, withSketchFont };
|
|
17867
|
+
export { CARTESIAN_TYPES, CHART_TYPES, CanvasLayer, DEFAULT_ENTRANCE_DURATION, DEFAULT_ROUGH_STYLE, DEFAULT_UPDATE_DURATION, DIM_ALPHA, ExpressionError, FACETABLE_TYPES2 as FACETABLE_TYPES, FUNCTION_NAMES, InteractionController, RoughPen, SKETCH_FONT_FAMILY, SKETCH_FONT_NAME, SLICER_TYPES, SR_ONLY_STYLE, Surface, TICK_SIZE, TIME_UNITS, TRANSFORM_KINDS, Tooltip, VERSION, aggregateValues, analyzeChart, animate, applyA11y, applyAggregate, applyBin, applyCalculate, applyFilter, applyFold, applyPatch, applyPick, applyTimeUnit, applyTransform, applyTransforms, arc, area, assertValidSpec, autoInsightAnnotations, backOut, bandScale, bounceOut, buildCartesianInteraction, buildCartesianModel, buildComboModel, buildDataTableFallback, buildFacetModels, buildHistogramModel, buildRenderReport, cartesianInteractionBuilders, cartesianRenderers, categorical, categoricalSchemes, chartTitleText, chartTypeLabel, checkExpression, clamp012 as clamp01, clamp255, compileExpression, compilePredicate, computeBackingSize, computeBins, computeFrame, computeSeriesInsights, contrastRatio, createDiv, createRoughPen, createSelectionStore, cubicIn, cubicInOut, cubicOut, curveCatmullRom, curveLinear, curveMonotoneX, curveStep, curveStepAfter, curveStepBefore, customRenderers, darkTheme, decimate, defaultSpan, dependentParams, diverging, divergingColorScale, divergingSchemes, drawAnnotationLabels, drawAnnotations, drawAxesUnderlay, drawFacet, drawLegend, drawOverlay, drawTitle, drawTrendlineLabels, drawTrendlines, easings, elasticOut, ellipsize, ensureSketchFont, expoInOut, expoOut, facetReport, fillParentStyle, filterRows, fontString, formatDate, formatNumber2 as formatNumber, formatValue, getDevicePixelRatio, groupBy, hashString, inferFieldType, inferFieldTypes, inferValueType, inheritInto, interpolateArray, interpolateNumber, interpolateNumberArray, interpolateObject, interpolateOklab, interpolateRgb, isBrowser, isEmptyValue, isFaceted, isParamClause, isSlicerType, keyFields, legendSwatchWidth, lightTheme, line, linear, linearRegression, linearScale, linearToSrgb, lintSpec, literalToValue, logScale, lttbRun, makeMatcher, matchesValue, measureText, monotoneXTangents, mulberry32, niceDomain, oklabToOklch, oklabToRgb, oklchToOklab, ordinalColorScale, overlayTextToCanvasCmd, paintCanvasText, paintLegendSwatch, parseColor, parseNumberFormat, pivot, placeViews, pointScale, polygonHachureLines, prefersReducedMotion, prettyDate, profileColumns, quadIn, quadInOut, quadOut, rampFromStops, readableTextColor, recommendChart, relativeLuminance, render, renderDashboard, renderToContext, repairReport, repairSpec, resolveCurve, resolveDashboardLayout, resolveDashboardSections, resolveEmphasis, resolveEntrance, resolveFilterValues, resolveInsightOptions, resolveSketch, resolveTheme, resolveUpdate, rgbToOklab, rgbaToCss, rotatePoint, roundedRect, sampleArc, sequential, sequentialColorScale, sequentialSchemes, setMeasureContext, setStyle, sinInOut, slicerParamName, smartDate, specFields, srgbToLinear, summarize, summarizeChart, themes, tickIncrement, tickStep, ticks, timeScale, timeTickFormat, timeTicks, toHex, toPointer, tooltipEnabled, truncateTo, validateDashboard, validateSpec, wireViews, withAlpha, withSketchFont };
|
|
17235
17868
|
//# sourceMappingURL=index.js.map
|
|
17236
17869
|
//# sourceMappingURL=index.js.map
|