mevebu 1.11.0 → 2.1.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.
@@ -1,56 +1,6 @@
1
1
  // Reads CSV or delimited text as a table, with row and column counts.
2
2
  import { el, bench, stack, panel, textArea, selectInput, fileInput, notice, copyButton, button, actions, toolcard } from "../core.js";
3
-
4
- function parseDelimited(text, delimiter) {
5
- const rows = [];
6
- let row = [];
7
- let field = "";
8
- let quoted = false;
9
- for (let i = 0; i < text.length; i++) {
10
- const char = text[i];
11
- if (quoted) {
12
- if (char === '"') {
13
- if (text[i + 1] === '"') { field += '"'; i++; }
14
- else quoted = false;
15
- } else field += char;
16
- } else if (char === '"') {
17
- quoted = true;
18
- } else if (char === delimiter) {
19
- row.push(field);
20
- field = "";
21
- } else if (char === "\n") {
22
- row.push(field);
23
- rows.push(row);
24
- row = [];
25
- field = "";
26
- } else if (char !== "\r") {
27
- field += char;
28
- }
29
- }
30
- if (field !== "" || row.length) {
31
- row.push(field);
32
- rows.push(row);
33
- }
34
- return rows;
35
- }
36
-
37
- function detectDelimiter(text) {
38
- const candidates = [",", ";", "\t", "|"];
39
- const first = text.split(/\r?\n/)[0] ?? "";
40
- const counts = { ",": 0, ";": 0, "\t": 0, "|": 0 };
41
- let quoted = false;
42
- for (let i = 0; i < first.length; i++) {
43
- const char = first[i];
44
- if (char === '"') quoted = !quoted;
45
- else if (!quoted && char in counts) counts[char]++;
46
- }
47
- let best = ",";
48
- let bestCount = 0;
49
- for (const candidate of candidates) {
50
- if (counts[candidate] > bestCount) { bestCount = counts[candidate]; best = candidate; }
51
- }
52
- return best;
53
- }
3
+ import { parseDelimited, detectDelimiter } from "../csv.js";
54
4
 
55
5
  function tile(label) {
56
6
  const value = el("span", { class: "wrap-anywhere text-xl font-bold tracking-[-0.02em] tabular-nums" }, "0");
@@ -0,0 +1,204 @@
1
+ // The document a dataset needs before anyone uses it: one row per column, with
2
+ // the type, the null count, the distinct count, the range and a sample.
3
+ import {
4
+ el, toast, download, bench, stack, panel, button, actions, toolcard, blank,
5
+ notice, output,
6
+ } from "../core.js";
7
+ import { dropzone, baseName, formatBytes } from "../files.js";
8
+ import { parseDelimited, detectDelimiter, headerKeys, toCsv } from "../csv.js";
9
+
10
+ const TYPE_LABELS = {
11
+ number: "number",
12
+ date: "date",
13
+ boolean: "boolean",
14
+ text: "text",
15
+ empty: "empty",
16
+ };
17
+
18
+ function guessType(values) {
19
+ const sample = values.filter((value) => value !== "");
20
+ if (!sample.length) return "empty";
21
+ if (sample.every((value) => /^-?\d+(\.\d+)?$/.test(value))) return "number";
22
+ if (sample.every((value) => /^\d{4}-\d{2}-\d{2}$/.test(value))) return "date";
23
+ if (sample.every((value) => /^(true|false)$/i.test(value))) return "boolean";
24
+ return "text";
25
+ }
26
+
27
+ function summarise(name, values) {
28
+ const filled = values.filter((value) => value !== "");
29
+ const nulls = values.length - filled.length;
30
+ const distinct = new Set(filled).size;
31
+ const type = guessType(filled);
32
+
33
+ let min = "";
34
+ let max = "";
35
+ if (type === "number") {
36
+ const numbers = filled.map(Number);
37
+ min = String(Math.min(...numbers));
38
+ max = String(Math.max(...numbers));
39
+ } else if (type === "date" || type === "text") {
40
+ const sorted = [...filled].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
41
+ min = sorted[0] ?? "";
42
+ max = sorted[sorted.length - 1] ?? "";
43
+ }
44
+
45
+ const counts = new Map();
46
+ for (const value of filled) counts.set(value, (counts.get(value) ?? 0) + 1);
47
+ const top = [...counts].sort((a, b) => b[1] - a[1])[0];
48
+
49
+ return {
50
+ name,
51
+ type,
52
+ nulls,
53
+ distinct,
54
+ min,
55
+ max,
56
+ sample: filled[0] ?? "",
57
+ top: top ? `${top[0]} (${top[1]})` : "",
58
+ };
59
+ }
60
+
61
+ export function mount(slot) {
62
+ let file = null;
63
+ let header = [];
64
+ let rows = [];
65
+ let summaryRows = [];
66
+
67
+ const error = notice("warn");
68
+ const summary = el("p", { class: "text-sm text-ink-2" });
69
+ const table = el("div", { class: "overflow-x-auto rounded-r2 border border-orbit" });
70
+ const markdown = output({ rows: 12 });
71
+ const empty = blank("table", "Drop a CSV to describe it.");
72
+
73
+ const drop = dropzone({ label: "Drop a CSV here", accept: ".csv,text/csv,text/plain", iconName: "table" });
74
+ const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
75
+ const copyButton = button("Copy Markdown", {
76
+ icon: "copy",
77
+ onclick: async () => {
78
+ if (!markdown.textContent) return;
79
+ try {
80
+ await navigator.clipboard.writeText(markdown.textContent);
81
+ toast("Dictionary copied");
82
+ } catch {
83
+ error.write("The clipboard is blocked here. Download the file instead.");
84
+ }
85
+ },
86
+ });
87
+ const downloadMarkdown = button("Download .md", {
88
+ icon: "download",
89
+ onclick: () => {
90
+ if (!markdown.textContent) return;
91
+ download(`${baseName(file?.name || "data")}-dictionary.md`, markdown.textContent, "text/markdown");
92
+ },
93
+ });
94
+ const downloadCsv = button("Download CSV", { icon: "download", kind: "fill" });
95
+
96
+ function renderTable() {
97
+ empty.hidden = summaryRows.length > 0;
98
+ table.hidden = summaryRows.length === 0;
99
+ markdown.hidden = summaryRows.length === 0;
100
+ if (!summaryRows.length) {
101
+ table.replaceChildren();
102
+ markdown.write("");
103
+ summary.textContent = "";
104
+ return;
105
+ }
106
+
107
+ const columns = ["Column", "Type", "Nulls", "Distinct", "Min", "Max", "Sample"];
108
+ const head = el("div", {
109
+ class: "grid gap-3 border-b border-orbit bg-deck-2 px-3 py-2 text-2xs font-bold tracking-[0.06em] text-ink-3 uppercase [grid-template-columns:minmax(90px,1fr)_70px_52px_64px_minmax(70px,.9fr)_minmax(70px,.9fr)_minmax(90px,1.1fr)]",
110
+ }, ...columns.map((cell) => el("span", {}, cell)));
111
+
112
+ const body = summaryRows.map((row) =>
113
+ el("div", {
114
+ class: "grid gap-3 border-b border-orbit px-3 py-2 text-sm last:border-b-0 [grid-template-columns:minmax(90px,1fr)_70px_52px_64px_minmax(70px,.9fr)_minmax(70px,.9fr)_minmax(90px,1.1fr)]",
115
+ },
116
+ el("span", { class: "truncate font-semibold" }, row.name),
117
+ el("span", { class: "text-2xs text-ink-3" }, TYPE_LABELS[row.type] ?? row.type),
118
+ el("span", { class: "font-mono text-2xs tabular-nums" }, String(row.nulls)),
119
+ el("span", { class: "font-mono text-2xs tabular-nums" }, String(row.distinct)),
120
+ el("span", { class: "truncate font-mono text-2xs" }, row.min),
121
+ el("span", { class: "truncate font-mono text-2xs" }, row.max),
122
+ el("span", { class: "truncate font-mono text-2xs text-ink-3" }, row.sample))
123
+ );
124
+
125
+ table.replaceChildren(head, ...body);
126
+ }
127
+
128
+ function renderMarkdown() {
129
+ const lines = [
130
+ `| Column | Type | Nulls | Distinct | Min | Max | Sample |`,
131
+ `| --- | --- | ---: | ---: | --- | --- | --- |`,
132
+ ...summaryRows.map((row) =>
133
+ `| ${row.name} | ${row.type} | ${row.nulls} | ${row.distinct} | ${row.min} | ${row.max} | ${row.sample} |`),
134
+ ];
135
+ markdown.write(lines.join("\n"));
136
+ }
137
+
138
+ function run() {
139
+ summaryRows = header.map((name, index) => summarise(name, rows.map((row) => row[index] ?? "")));
140
+ const nulls = summaryRows.reduce((total, row) => total + row.nulls, 0);
141
+ summary.textContent = `${rows.length} row${rows.length === 1 ? "" : "s"} · ${header.length} column${header.length === 1 ? "" : "s"} · ${nulls} empty cell${nulls === 1 ? "" : "s"}`;
142
+ renderTable();
143
+ renderMarkdown();
144
+ }
145
+
146
+ async function open(files) {
147
+ const picked = Array.from(files).find((entry) => /\.(csv|tsv|txt)$/i.test(entry.name) || entry.type.startsWith("text/"));
148
+ if (!picked) {
149
+ error.write("That file is not a CSV.");
150
+ return;
151
+ }
152
+ try {
153
+ const text = await picked.text();
154
+ const parsed = parseDelimited(text, detectDelimiter(text));
155
+ if (parsed.length < 2) throw new Error("A CSV needs a header row and at least one data row.");
156
+ file = picked;
157
+ header = headerKeys(parsed[0]);
158
+ rows = parsed.slice(1).filter((row) => row.some((cell) => cell !== ""));
159
+ error.write("");
160
+ run();
161
+ } catch (caught) {
162
+ error.write(caught?.message || "This file could not be read.");
163
+ }
164
+ }
165
+
166
+ drop.onFiles(open);
167
+ clearButton.addEventListener("click", () => {
168
+ file = null;
169
+ header = [];
170
+ rows = [];
171
+ summaryRows = [];
172
+ error.write("");
173
+ renderTable();
174
+ });
175
+ downloadCsv.addEventListener("click", () => {
176
+ if (!summaryRows.length) return;
177
+ const csv = toCsv([
178
+ ["column", "type", "nulls", "distinct", "min", "max", "sample", "top"],
179
+ ...summaryRows.map((row) => [row.name, row.type, row.nulls, row.distinct, row.min, row.max, row.sample, row.top]),
180
+ ]);
181
+ download(`${baseName(file?.name || "data")}-dictionary.csv`, csv, "text/csv");
182
+ });
183
+
184
+ renderTable();
185
+
186
+ const shell = toolcard({
187
+ body: bench(
188
+ stack(
189
+ drop,
190
+ panel("What it reports", el("p", { class: "m-0 text-sm leading-[1.6] text-ink-3" },
191
+ "The type it detects, empty cells, distinct values, the smallest and largest value, and one sample. A type can be wrong on a mixed column, so check it before you publish.")),
192
+ error
193
+ ),
194
+ stack(summary, empty, table, markdown)
195
+ ),
196
+ foot: actions(clearButton, copyButton, downloadMarkdown, downloadCsv),
197
+ });
198
+
199
+ slot.append(shell.node);
200
+
201
+ return {};
202
+ }
203
+
204
+ export default { mount };
@@ -0,0 +1,181 @@
1
+ // Split a CSV into train, validation and test sets. Seeded, so the same seed
2
+ // and the same file always give the same split, and a row never lands in two
3
+ // sets.
4
+ import {
5
+ el, toast, download, bench, stack, panel, numberInput, textInput, selectInput,
6
+ button, actions, toolcard, blank, notice,
7
+ } from "../core.js";
8
+ import { dropzone, baseName, formatBytes } from "../files.js";
9
+ import { parseDelimited, detectDelimiter, headerKeys, toCsv } from "../csv.js";
10
+ import { seeded, shuffle } from "../random.js";
11
+ import { zip } from "../zip.js";
12
+
13
+ export function mount(slot) {
14
+ let file = null;
15
+ let header = [];
16
+ let rows = [];
17
+ let split = null;
18
+
19
+ const train = numberInput({ min: "0", max: "100", step: "5", value: "70", "aria-label": "Train percent" });
20
+ const validation = numberInput({ min: "0", max: "100", step: "5", value: "15", "aria-label": "Validation percent" });
21
+ const seed = textInput({ value: "mevebu", "aria-label": "Seed", class: "font-mono" });
22
+ const stratify = selectInput([{ value: "", label: "Do not stratify" }], { value: "", "aria-label": "Stratify by" });
23
+
24
+ const error = notice("warn");
25
+ const summary = el("p", { class: "text-sm text-ink-2" });
26
+ const tiles = el("div", { class: "grid grid-cols-3 gap-2" });
27
+ const empty = blank("table", "Drop a CSV to split it.");
28
+
29
+ const drop = dropzone({ label: "Drop a CSV here", accept: ".csv,text/csv,text/plain", iconName: "table" });
30
+ const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
31
+ const splitButton = button("Split and download", { icon: "download", kind: "fill" });
32
+
33
+ function tile(label) {
34
+ const value = el("span", { class: "text-xl font-bold tracking-[-0.02em] tabular-nums" }, "0");
35
+ const rows = el("span", { class: "text-2xs text-ink-3" }, "");
36
+ const node = el("div", { class: "grid gap-0.5 rounded-r1 border border-orbit bg-deck-2 px-3 py-2.5" },
37
+ el("span", { class: "text-2xs font-semibold tracking-[0.06em] text-ink-3 uppercase" }, label),
38
+ value, rows);
39
+ node.set = (count, percent) => {
40
+ value.textContent = String(count);
41
+ rows.textContent = `${percent}% of rows`;
42
+ };
43
+ return node;
44
+ }
45
+
46
+ const trainTile = tile("Train");
47
+ const validationTile = tile("Validation");
48
+ const testTile = tile("Test");
49
+ tiles.append(trainTile, validationTile, testTile);
50
+
51
+ function weights() {
52
+ const a = Math.max(0, Number(train.value) || 0);
53
+ const b = Math.max(0, Number(validation.value) || 0);
54
+ const c = Math.max(0, 100 - a - b);
55
+ return { train: a, validation: b, test: c };
56
+ }
57
+
58
+ function compute() {
59
+ const w = weights();
60
+ const random = seeded(seed.value || "mevebu");
61
+ const key = stratify.value;
62
+ const keyIndex = key ? header.indexOf(key) : -1;
63
+
64
+ const buckets = new Map();
65
+ for (const row of rows) {
66
+ const group = keyIndex >= 0 ? row[keyIndex] ?? "" : "__all__";
67
+ if (!buckets.has(group)) buckets.set(group, []);
68
+ buckets.get(group).push(row);
69
+ }
70
+
71
+ const out = { train: [], validation: [], test: [] };
72
+ for (const group of buckets.values()) {
73
+ const list = shuffle(group, random);
74
+ const n = list.length;
75
+ const trainEnd = Math.round((w.train / 100) * n);
76
+ const validationEnd = trainEnd + Math.round((w.validation / 100) * n);
77
+ out.train.push(...list.slice(0, trainEnd));
78
+ out.validation.push(...list.slice(trainEnd, validationEnd));
79
+ out.test.push(...list.slice(validationEnd));
80
+ }
81
+
82
+ // Shuffle the final sets so a stratified split is not grouped by class.
83
+ for (const name of Object.keys(out)) out[name] = shuffle(out[name], random);
84
+ return out;
85
+ }
86
+
87
+ function render() {
88
+ empty.hidden = rows.length > 0;
89
+ tiles.hidden = rows.length === 0;
90
+ splitButton.disabled = rows.length === 0;
91
+ clearButton.disabled = rows.length === 0;
92
+ if (!rows.length) {
93
+ summary.textContent = "";
94
+ return;
95
+ }
96
+ const w = weights();
97
+ split = compute();
98
+ const total = rows.length;
99
+ trainTile.set(split.train.length, Math.round((split.train.length / total) * 100));
100
+ validationTile.set(split.validation.length, Math.round((split.validation.length / total) * 100));
101
+ testTile.set(split.test.length, Math.round((split.test.length / total) * 100));
102
+ summary.textContent = `${total} rows · ${header.length} columns · seed ${seed.value || "mevebu"}${w.test < 0 ? " · the percentages add to more than 100, so test is empty" : ""}`;
103
+ if (w.test < 0) error.write("Train and validation add to more than 100 percent. Test gets nothing.");
104
+ else error.write("");
105
+ }
106
+
107
+ async function open(files) {
108
+ const picked = Array.from(files).find((entry) => /\.(csv|tsv|txt)$/i.test(entry.name) || entry.type.startsWith("text/"));
109
+ if (!picked) {
110
+ error.write("That file is not a CSV.");
111
+ return;
112
+ }
113
+ try {
114
+ const text = await picked.text();
115
+ const parsed = parseDelimited(text, detectDelimiter(text));
116
+ if (parsed.length < 2) throw new Error("A CSV needs a header row and at least one data row.");
117
+ file = picked;
118
+ header = headerKeys(parsed[0]);
119
+ rows = parsed.slice(1).filter((row) => row.some((cell) => cell !== ""));
120
+ stratify.replaceChildren(
121
+ el("option", { value: "" }, "Do not stratify"),
122
+ ...header.map((name) => el("option", { value: name }, name))
123
+ );
124
+ stratify.value = "";
125
+ error.write("");
126
+ render();
127
+ } catch (caught) {
128
+ error.write(caught?.message || "This file could not be read.");
129
+ }
130
+ }
131
+
132
+ drop.onFiles(open);
133
+ train.addEventListener("input", render);
134
+ validation.addEventListener("input", render);
135
+ seed.addEventListener("input", render);
136
+ stratify.addEventListener("change", render);
137
+
138
+ splitButton.addEventListener("click", () => {
139
+ if (!split) return;
140
+ const base = baseName(file?.name || "data");
141
+ const files = ["train", "validation", "test"].map((name) => ({
142
+ name: `${base}-${name}.csv`,
143
+ bytes: new TextEncoder().encode(toCsv([header, ...split[name]])),
144
+ }));
145
+ download(`${base}-split.zip`, zip(files), "application/zip");
146
+ toast("Three files ready");
147
+ });
148
+
149
+ clearButton.addEventListener("click", () => {
150
+ file = null;
151
+ header = [];
152
+ rows = [];
153
+ split = null;
154
+ error.write("");
155
+ render();
156
+ });
157
+
158
+ render();
159
+
160
+ const shell = toolcard({
161
+ body: bench(
162
+ stack(
163
+ drop,
164
+ el("div", { class: "grid grid-cols-2 gap-3" }, panel("Train %", train), panel("Validation %", validation)),
165
+ panel("Seed", seed, "The same seed and file give the same split."),
166
+ panel("Stratify by", stratify, "Keeps each class proportional across the three sets."),
167
+ error
168
+ ),
169
+ stack(summary, empty, tiles,
170
+ el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
171
+ "Every row goes to exactly one set. The test share is whatever is left after train and validation."))
172
+ ),
173
+ foot: actions(clearButton, splitButton),
174
+ });
175
+
176
+ slot.append(shell.node);
177
+
178
+ return {};
179
+ }
180
+
181
+ export default { mount };