├── .gitignore
├── LICENSE
├── README.md
├── data
└── gendata.ts
├── index.html
├── package.json
├── rollup.config.mjs
├── src
├── main.css
└── main.ts
└── tsconfig.json
/.gitignore:
--------------------------------------------------------------------------------
1 | build
2 | dist
3 |
4 | node_modules
5 |
6 | .rollup.cache
7 | .DS_Store
8 | .idea
9 | .vscode
10 | .npm-debug
11 | npm-debug.log
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Leon Sorokin
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## 𝌠 μTable
2 |
3 | A tiny, fast UI for viewing, sorting, and filtering CSVs _(MIT Licensed)_
4 |
5 | ---
6 | ### Introduction
7 |
8 | μTable is a fast interface for viewing CSV files.
9 | It draws much inspiration from [thoughtspile/hippotable](https://github.com/thoughtspile/hippotable), but makes different tech choices with the goal of being smaller and faster.
10 |
11 |
12 |
13 |
14 |
15 | μTable
16 | Hippotable
17 |
18 |
19 |
20 |
21 | JS Framework
22 | ivi
23 | SolidJS
24 |
25 |
26 | CSV Parsing
27 | μDSV
28 | Arquero (d3-dsv)
29 |
30 |
31 | Virtualization
32 | own (< 0.5 KB)
33 | TanStack/table (~60 KB)
34 |
35 |
36 | Sorting / filtering
37 | own + μExpr (< 4 KB)
38 | Arquero (~400 KB)
39 |
40 |
41 | localStorage Persistence
42 | no
43 | yes
44 |
45 |
46 |
47 |
48 | Bundle size
49 | 20 KB
50 | 416 KB
51 |
52 |
53 |
54 |
55 | Both projects are very early, and the choices made by Hippotable are totally sensible, considering its plans to leverage much more of the Arquero library.
56 | I have, however, previously tested some Arquero functions (such as grouping) and found its performance to be lacking in multiple areas.
57 |
58 | uTable has similar goals as Hippotable, but [perhaps] with greater scrutiny on external dependencies, and will always roll its own solutions when there are significant performance and/or size benefits.
59 |
60 | ---
61 | ### Features
62 |
63 | ---
64 | ### How to use
65 |
66 | **Statically hosted:**
67 |
68 | 1. Open https://leeoniya.github.io/uTable
69 | 2. Drag/drop a CSV file into the UI
70 |
71 | **Locally or dev:**
72 |
73 | 1. Clone this repo
74 | 2. Install dependencies: `npm install`
75 | 3. Build bundle: `npm run build`
76 | 4. Run an http server in repo root that can serve static files, for example:
77 | 1. Install: `npm i -g http-server`
78 | 2. Run `http-server` in repo root
79 | 5. Open `http://localhost:8080/`
80 | 6. Drag/drop a CSV file into the UI
81 |
82 | ---
83 | ### Performance
--------------------------------------------------------------------------------
/data/gendata.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 |
3 | const random = (max: number) => Math.round(Math.random() * 1000) % max;
4 | const A = ["pretty", "large", "big", "small", "tall", "short", "long", "handsome", "plain", "quaint", "clean", "elegant", "easy", "angry", "crazy", "helpful", "mushy", "odd", "unsightly", "adorable", "important", "inexpensive", "cheap", "expensive", "fancy"];
5 | const C = ["red", "yellow", "blue", "green", "pink", "brown", "purple", "brown", "white", "black", "orange"];
6 | const N = ["table", "chair", "house", "bbq", "desk", "car", "pony", "cookie", "sandwich", "burger", "pizza", "mouse", "keyboard"];
7 |
8 | let nextId = 0;
9 | function buildData(count: number) {
10 | const data: string[] = [
11 | 'id,adjective,color,noun'
12 | ];
13 |
14 | for (let i = 0; i < count; i++)
15 | data.push(`${nextId++},${A[random(A.length)]},${C[random(C.length)]},${N[random(N.length)]}`);
16 | return data.join('\n');
17 | }
18 |
19 | let recs = buildData(3e5);
20 |
21 | fs.writeFileSync('./table.csv', recs);
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | uTable
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "utable",
3 | "type": "module",
4 | "version": "0.0.1",
5 | "author": "Leon Sorokin",
6 | "license": "MIT",
7 | "homepage": "https://github.com/leeoniya/uTable",
8 | "repository": "https://github.com/leeoniya/uTable",
9 | "keywords": [],
10 | "scripts": {
11 | "build": "rollup -c"
12 | },
13 | "dependencies": {
14 | "ivi": "4.0.1",
15 | "udsv": "0.7.2",
16 | "uexpr": "leeoniya/uExpr#404558e29b298d903a7b85a1de45e856f94de7c3"
17 | },
18 | "devDependencies": {
19 | "@ivi/rollup-plugin": "4.0.0",
20 | "@rollup/plugin-node-resolve": "16.0.1",
21 | "@rollup/plugin-terser": "0.4.4",
22 | "@rollup/plugin-typescript": "12.1.2",
23 | "@swc/core": "^1.11.18",
24 | "rollup": "4.39.0",
25 | "rollup-plugin-swc3": "^0.12.1",
26 | "tslib": "2.8.1",
27 | "typescript": "5.8.3"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/rollup.config.mjs:
--------------------------------------------------------------------------------
1 | import { nodeResolve } from "@rollup/plugin-node-resolve";
2 | import typescript from "@rollup/plugin-typescript";
3 | import { ivi } from "@ivi/rollup-plugin";
4 | import { minify, defineRollupSwcMinifyOption } from 'rollup-plugin-swc3'
5 | import fs from 'fs';
6 |
7 | const name = 'main';
8 |
9 | const copyCss = () => {
10 | return {
11 | name: 'copyCss',
12 | closeBundle: () => {
13 | fs.copyFileSync(`./src/${name}.css`, './dist/styles.css');
14 | },
15 | }
16 | };
17 |
18 | export default [
19 | {
20 | input: `./src/${name}.ts`,
21 | output: {
22 | file: "./dist/bundle.min.js",
23 | format: "es",
24 | strict: true,
25 | sourcemap: true,
26 | },
27 | watch: {
28 | clearScreen: false,
29 | },
30 | plugins: [
31 | nodeResolve(),
32 | typescript(),
33 | ivi({}),
34 | minify(
35 | defineRollupSwcMinifyOption({
36 | compress: {
37 | inline: 0,
38 | keep_infinity: true,
39 | },
40 | toplevel: true,
41 | module: true,
42 | sourceMap: true,
43 | })
44 | ),
45 | copyCss()
46 | ],
47 | },
48 | ];
--------------------------------------------------------------------------------
/src/main.css:
--------------------------------------------------------------------------------
1 | html {
2 | box-sizing: border-box;
3 | }
4 |
5 | *,
6 | *:before,
7 | *:after {
8 | box-sizing: inherit;
9 | }
10 |
11 | body {
12 | margin: 0;
13 | }
14 |
15 | .scroll-wrap {
16 | position: relative;
17 |
18 | border: 1px solid #dadee4;
19 | width: 100%;
20 | height: 100vh;
21 | overflow-y: auto;
22 | /* overflow-x: hidden; */
23 | overflow-anchor: none;
24 | }
25 |
26 | table {
27 | width: 100%;
28 | border-collapse: collapse;
29 |
30 | font-family: monospace;
31 | font-size: 12px;
32 | color: #3b4351;
33 | }
34 |
35 | tr {
36 | height: 18px;
37 | }
38 |
39 | th,
40 | td {
41 | padding: 0 6px;
42 | text-overflow: ellipsis;
43 | overflow: hidden;
44 | max-width: 1000px;
45 | white-space: nowrap;
46 | }
47 |
48 | /* td:hover {
49 | overflow: visible;
50 | white-space: unset;
51 | } */
52 |
53 | thead {
54 | text-align: left;
55 | background: #eee;
56 | position: sticky;
57 | top: 0px;
58 | border-bottom: 1px solid #dadee4;
59 | }
60 |
61 | tbody tr {
62 | border-bottom: 1px solid #dadee4;
63 | }
64 |
65 | tbody tr:hover {
66 | background: #bbdefb;
67 | }
68 |
69 | .col-names th {
70 | position: relative;
71 | cursor: pointer;
72 | padding: 6px;
73 | user-select: none;
74 | }
75 |
76 | .col-names th .col-resize {
77 | position: absolute;
78 | right: 0;
79 | top: 0;
80 |
81 | width: 5px;
82 | height: 100%;
83 |
84 | cursor: col-resize;
85 | }
86 |
87 | .col-filts th {
88 | padding: 0 6px 6px 6px;
89 | }
90 |
91 | .col-filts select {
92 | position: absolute;
93 | background: none;
94 | padding: 2px;
95 | }
96 |
97 | .col-filts input {
98 | padding-left: 40px;
99 | width: 100%;
100 | min-width: 100px;
101 | max-width: 100%;
102 | }
103 |
104 | .col-sort {
105 | font-size: 16px;
106 | line-height: 0;
107 | margin-left: 5px;
108 | }
109 |
110 | .col-sort sup {
111 | font-size: 10px;
112 | }
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { html, update, component, getProps, createRoot, useState, invalidate, useEffect, List } from "ivi";
2 | import { Schema, inferSchema, initParser, type SchemaColumn } from "udsv";
3 | import { Op, Expr, compileFilter } from 'uexpr';
4 |
5 | type HTMLElementEvent = Event & {
6 | target: T;
7 | }
8 |
9 | interface Table {
10 | schema: Schema;
11 | data: string[][];
12 |
13 | // rows?: number[] | null;
14 | // cols?:
15 |
16 | // filters: (various matchers, uExpr)
17 | // sorters:
18 | // groupers:
19 | // faceters: (enum values)
20 | }
21 |
22 | function haltEvent(e: Event) {
23 | e.preventDefault();
24 | e.stopPropagation();
25 | e.stopImmediatePropagation();
26 | }
27 |
28 | function onWinCap(type: string, fn: EventListener) {
29 | window.addEventListener(type, fn, {capture: true});
30 | }
31 |
32 | function offWinCap(type: string, fn: EventListener) {
33 | window.removeEventListener(type, fn, {capture: true});
34 | }
35 |
36 | interface CSVDropperProps {
37 | setData: (table: Table | null) => void;
38 | }
39 | type Sorter = [pos: number, colIdx: number, sortDir: number];
40 | type TupleSortFn = (a: string[], b: string[]) => number;
41 |
42 | const cmp = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }).compare;
43 |
44 | const compileSorterTuples = (cols: SchemaColumn[], pos: number[], dir: number[], simple = false): TupleSortFn | null => {
45 | let sorts: Sorter[] = [];
46 |
47 | for (let ci = 0; ci < dir.length; ci++) {
48 | if (dir[ci] != 0)
49 | sorts.push([pos[ci], ci, dir[ci]]);
50 | }
51 |
52 | if (sorts.length == 0)
53 | return null;
54 |
55 | sorts.sort((a, b) => a[0] - b[0]);
56 |
57 | // todo: handle nulls?
58 | let body = sorts.map(s => {
59 | let col = cols[s[1]];
60 | let a = `a[${s[1]}]`;
61 | let b = `b[${s[1]}]`;
62 |
63 | return (
64 | col.type == 'n' ? `${s[2]} * (${a} - ${b})` :
65 | simple ? `${s[2]} * (${a} > ${b} ? 1 : ${a} < ${b} ? -1 : 0)` :
66 | `${s[2]} * cmp(${a}, ${b})`
67 | );
68 | }).join(' || ');
69 |
70 | return new Function('cmp', `
71 | return (a, b) => ${body};
72 | `)(cmp);
73 | };
74 |
75 | const compileMatcherStringTuples = (rules: Expr[]) => {
76 | let nonEmpty = rules.filter(r => r[2] != '');
77 |
78 | if (nonEmpty.length == 0)
79 | return (data: string[][]) => data;
80 |
81 | // add null handling, maybe for strings uExpr should parse as '' and nums as 0?
82 | let rules2 = ['&&',
83 | ...nonEmpty.flatMap(r => [
84 | ['!==', r[1], null],
85 | r,
86 | ])
87 | ] as unknown as Expr;
88 |
89 | // add uFuzzy, case insensitivity?
90 |
91 | // todo: make empty value acceptable, (isNull, falsy, cmp against explicit empty, etc)
92 | return compileFilter(rules2);
93 | };
94 |
95 | const CSVDropper = component((c) => {
96 | let onDrop = (e: DragEvent) => {
97 | e.preventDefault();
98 |
99 | for (const item of e.dataTransfer!.items) {
100 | if (item.kind == "file") {
101 | let file = item.getAsFile()!;
102 |
103 | if (file.name.endsWith(".csv")) {
104 | file.text().then((text) => {
105 | console.time("parse");
106 |
107 | let s = inferSchema(text, {}, 100);
108 |
109 | // we dont need to parse dates except during display? they can be sorted by timestamp?
110 | s.cols.forEach(c => {
111 | if (c.type === 'd')
112 | c.type = 's';
113 | });
114 |
115 | let p = initParser(s);
116 | // let d = p.stringArrs(text);
117 | let d = p.typedArrs(text);
118 |
119 | console.timeEnd("parse");
120 |
121 | getProps(c).setData({schema: s, data: d});
122 | });
123 | }
124 | }
125 | }
126 | };
127 |
128 | let onDragOver = (e: DragEvent) => {
129 | e.preventDefault();
130 | };
131 |
132 | return () => html`
133 |
141 | Drag/drop CSV here...
142 |
143 | `;
144 | });
145 |
146 | // const HeaderCell = component((c) => {
147 | // return (props) => html`
148 | // 0 ? `${colWids[i]}px` : 'auto'}>
149 | //
150 | // ${c.name}
151 | // ${sortDir[i] != 0 ? html`${sortDir[i] == 1 ? `▲` : '▼'}${sortPos[i]} ` : null}
152 | //
153 | // `;
154 | // });
155 |
156 | const Table = component((c) => {
157 | let dom: HTMLElement;
158 | const setDom = (el: HTMLElement) => { dom = el; };
159 |
160 | let table = getProps(c);
161 | let cols = table.schema.cols;
162 |
163 | // this can come from url params? cookies?
164 | let filts: Expr[] = cols.map((c, ci) => ['*', `[${ci}]`, '']);
165 | let sortDir: number[] = Array(cols.length).fill(0);
166 | let sortPos: number[] = Array(cols.length).fill(0);
167 |
168 | let dataFilt = table.data;
169 | let dataSort = table.data;
170 |
171 | let onClickCol = (idx: number, shiftKey: boolean) => {
172 | let dir = sortDir[idx];
173 | let pos = sortPos[idx];
174 |
175 | if (dir == 1)
176 | dir = -1;
177 | else if (dir == 0) {
178 | if (!shiftKey) {
179 | // reset all sorts
180 | sortPos.fill(0);
181 | sortDir.fill(0);
182 | }
183 |
184 | dir = 1;
185 | pos = Math.max(...sortPos) + 1;
186 | }
187 | else {
188 | for (let i = 0; i < sortPos.length; i++)
189 | if (sortPos[i] > pos)
190 | sortPos[i]--;
191 |
192 | dir = 0;
193 | pos = 0;
194 | }
195 |
196 | sortDir[idx] = dir;
197 | sortPos[idx] = pos;
198 |
199 | reSort();
200 | };
201 |
202 | let reFilt = () => {
203 | dataFilt = compileMatcherStringTuples(filts)(table.data);
204 | reSort();
205 | };
206 |
207 | let reSort = () => {
208 | let sortFn = compileSorterTuples(cols, sortPos, sortDir);
209 |
210 | if (sortFn == null)
211 | dataSort = dataFilt;
212 | else
213 | dataSort = dataFilt.slice().sort(sortFn);
214 |
215 | // when to do this?
216 | dom.scrollTop = 0;
217 |
218 | invalidate(c);
219 | };
220 |
221 | let onChangeFiltOp = (idx: number, op: Op) => {
222 | filts[idx][0] = op;
223 | reFilt();
224 | };
225 |
226 | let onChangeFiltVal = (idx: number, val: string) => {
227 | filts[idx][2] = val;
228 | reFilt();
229 | };
230 |
231 | // approx row hgt to estimate how many to render based on viewport size
232 | let rowHgt = 0;
233 | let viewRows = 0;
234 | // min chunk length and used to estimate row height
235 | let chunkLen = Math.min(100, table.data.length);
236 | let idx0 = 0;
237 | let colWids = Array(cols.length).fill(null);
238 |
239 | const incrRoundDn = (num: number, incr: number) => Math.floor(num / incr) * incr;
240 |
241 | let sync = () => {
242 | let rFull = dom.getBoundingClientRect();
243 | let rThead = dom.querySelector('thead')!.getBoundingClientRect();
244 | let viewHgt = rFull.height - rThead.height;
245 |
246 | // set once during init from probed/rendered chunk
247 | if (rowHgt == 0) {
248 | let tbody = dom.querySelector('tbody')!;
249 | let rTbody = tbody.getBoundingClientRect();
250 |
251 | rowHgt = rTbody.height / chunkLen;
252 |
253 | let i = 0;
254 | for (let colEl of dom.querySelectorAll('.col-names th'))
255 | colWids[i++] = colEl.getBoundingClientRect().width;
256 | }
257 |
258 | viewRows = Math.floor(viewHgt / rowHgt);
259 | chunkLen = 2 * viewRows;
260 |
261 | // console.log(chunkLen);
262 | };
263 |
264 | let setIdx0 = (force = false) => {
265 | let idx1 = incrRoundDn(dom.scrollTop / rowHgt, viewRows);
266 |
267 | if (force || idx0 != idx1) {
268 | idx0 = idx1;
269 | invalidate(c);
270 | }
271 | };
272 |
273 | // useLayoutEffect(c, () => {
274 | // for (let colEl of dom.querySelectorAll('.col-names th'))
275 | // console.log(colEl.getBoundingClientRect().width);
276 | // })();
277 |
278 | useEffect(c, () => {
279 | // TODO: ensure this is only for vt scroll and resize, and handle hz resize independently (adjust col widths? only when table width 100%?)
280 | dom.addEventListener('scroll', () => setIdx0());
281 |
282 | let resizeObserver = new ResizeObserver(() => {
283 | sync();
284 | setIdx0(true);
285 | });
286 | resizeObserver.observe(dom);
287 |
288 | return () => {
289 | resizeObserver.unobserve(dom);
290 | resizeObserver.disconnect();
291 | };
292 | })();
293 |
294 | let onClicks = cols.map((c, i) => (e: MouseEvent) => onClickCol(i, e.shiftKey));
295 | let onChangeFiltOps = cols.map((c, i) => (e: HTMLElementEvent) => onChangeFiltOp(i, e.target.value as Op));
296 | let onChangeFiltVals = cols.map((c, i) => (e: HTMLElementEvent) => onChangeFiltVal(i, e.target.value));
297 |
298 | // todo: make configurable per column
299 | const min = 50;
300 | const max = 500;
301 |
302 | let onDowns = cols.map((col, i) => (e: MouseEventInit) => {
303 | if (e.button !== 0)
304 | return;
305 |
306 | let fromX = e.clientX!;
307 | let fromWid = colWids[i];
308 |
309 | let onMove: EventListener = (e: MouseEventInit) => {
310 | let newWid = fromWid + (e.clientX! - fromX);
311 |
312 | // clamp
313 | if (newWid > max || newWid < min)
314 | return;
315 |
316 | // ensure non-zero here
317 |
318 | colWids[i] = newWid;
319 |
320 | // TODO: invaldate only header component, or just th component?
321 | invalidate(c);
322 | };
323 |
324 | let onClick: EventListener = (e: MouseEventInit) => {
325 | offWinCap('mousemove', onMove);
326 | offWinCap('click', onClick);
327 | haltEvent(e as Event);
328 | };
329 |
330 | onWinCap('mousemove', onMove);
331 | onWinCap('click', onClick);
332 | haltEvent(e as Event);
333 | });
334 |
335 | const Row = component((c) => row => html`${row.map(Cell)} `, () => true);
336 | const Cell = component((c) => col => html``, () => true);
337 |
338 | // col resize/drag
339 | // let onMouseDowns = cols.map((c, i) => (e: MouseEvent) => onClickCol(i, e.shiftKey));
340 |
341 | return () => {
342 | let chunk = dataSort.slice(idx0, idx0 + chunkLen);
343 | // TODO: this will only change with filters
344 | let totalHgt = dataSort.length * rowHgt;
345 |
346 | let padTop = rowHgt == 0 ? 0 : idx0 * rowHgt;
347 | let padBtm = rowHgt == 0 ? 0 : totalHgt - Math.min(totalHgt, rowHgt * (idx0 + chunkLen));
348 |
349 | return html`
350 |
383 | `;
384 | }
385 | });
386 |
387 | const App = component((c) => {
388 | let [getData, setData] = useState(c, null);
389 |
390 | return () => {
391 | let table = getData();
392 | return table == null ? CSVDropper({ setData }) : Table(table);
393 | };
394 | });
395 |
396 | update(createRoot(document.body), App());
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "dist",
4 | "target": "es2022",
5 | "module": "NodeNext",
6 | "moduleResolution": "node16",
7 | "declaration": false,
8 | "sourceMap": true,
9 | "importHelpers": true,
10 | "noEmitHelpers": true,
11 | "removeComments": false,
12 | "allowSyntheticDefaultImports": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "strict": true,
15 | "noImplicitAny": true,
16 | "noImplicitReturns": true,
17 | "noUnusedLocals": true,
18 | "skipLibCheck": true
19 | },
20 | "include": [
21 | "src/**/*.ts"
22 | ],
23 | "exclude": [
24 | "src/bak/*.ts"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------