diff --git a/package.json b/package.json
index d0d2adf..8a00aee 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"version": "1.0.2",
"private": true,
"dependencies": {
+ "@duckdb/duckdb-wasm": "^1.33.1-dev45.0",
"@emotion/cache": "11.14.0",
"@emotion/react": "11.13.0",
"@emotion/styled": "11.13.0",
diff --git a/src/app/[locale]/gtfs-viewer/components/GtfsViewerClient.tsx b/src/app/[locale]/gtfs-viewer/components/GtfsViewerClient.tsx
new file mode 100644
index 0000000..f8eea13
--- /dev/null
+++ b/src/app/[locale]/gtfs-viewer/components/GtfsViewerClient.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+// Client Component wrapper that lazy-loads GtfsViewerClient with ssr:false.
+// This keeps DuckDB-WASM out of the server bundle and prevents the Turbopack
+// WASM chunking crash at build time. ssr:false is allowed here because this
+// is a Client Component.
+import dynamic from 'next/dynamic';
+
+const GtfsViewerClient = dynamic(
+ () => import('../../../components/gtfs-viewer/GtfsViewerClient'),
+ { ssr: false },
+);
+
+export default GtfsViewerClient;
diff --git a/src/app/[locale]/gtfs-viewer/page.tsx b/src/app/[locale]/gtfs-viewer/page.tsx
new file mode 100644
index 0000000..5984245
--- /dev/null
+++ b/src/app/[locale]/gtfs-viewer/page.tsx
@@ -0,0 +1,18 @@
+import { type ReactElement } from 'react';
+import { type Metadata } from 'next';
+import { routing } from '../../../i18n/routing';
+import { type Locale } from '../../../i18n/routing';
+import GtfsViewerClient from './components/GtfsViewerClient';
+
+export const metadata: Metadata = {
+ title: 'GTFS Viewer POC | MobilityDatabase',
+ description: 'Explore GTFS dataset tables with efficient pagination and search via DuckDB-WASM.',
+};
+
+export function generateStaticParams(): Array<{ locale: Locale }> {
+ return routing.locales.map((locale) => ({ locale }));
+}
+
+export default function GtfsViewerPage(): ReactElement {
+ return ;
+}
diff --git a/src/app/components/gtfs-viewer/GtfsViewerClient.tsx b/src/app/components/gtfs-viewer/GtfsViewerClient.tsx
new file mode 100644
index 0000000..f3120b5
--- /dev/null
+++ b/src/app/components/gtfs-viewer/GtfsViewerClient.tsx
@@ -0,0 +1,695 @@
+'use client';
+
+/**
+ * GTFS Viewer POC
+ *
+ * Loads Parquet files from a public GCS bucket (or local http.server) using
+ * DuckDB-WASM + HTTP Range requests. No backend required — all queries run
+ * in the browser.
+ *
+ * Workflow:
+ * 1. Paste a metadata.json URL → pick a table from the sidebar
+ * OR paste a direct .parquet URL → view it immediately
+ * 2. DuckDB fetches only the Parquet row groups that satisfy the search
+ * (same principle as PMTiles: index → byte-range fetch → render)
+ * 3. Pagination + per-column search work without loading the full file
+ */
+
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ Divider,
+ FormControl,
+ IconButton,
+ InputAdornment,
+ InputLabel,
+ MenuItem,
+ Paper,
+ Select,
+ Skeleton,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TablePagination,
+ TableRow,
+ TableSortLabel,
+ TextField,
+ Tooltip,
+ Typography,
+} from '@mui/material';
+import SearchIcon from '@mui/icons-material/Search';
+import ClearIcon from '@mui/icons-material/Clear';
+import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
+import OpenInNewIcon from '@mui/icons-material/OpenInNew';
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+interface TableMeta {
+ file: string;
+ row_count: number;
+ size_bytes: number;
+ columns: string[];
+ sort_columns: string[];
+ search_columns: string[];
+}
+
+interface GtfsMetadata {
+ source: string;
+ generated_at: string;
+ row_group_size: number;
+ tables: Record;
+}
+
+type SortDir = 'asc' | 'desc';
+
+interface GtfsViewerClientProps {
+ /** Pre-set URL (metadata.json or .parquet). Hides the URL input when provided. */
+ initialUrl?: string;
+ /** When true, suppresses the page header and URL input — for embedding in feed pages. */
+ embedded?: boolean;
+}
+
+// ─── DuckDB initialisation (singleton, loaded once) ─────────────────────────
+
+let dbPromise: Promise | null = null;
+
+async function getDuckDB(): Promise {
+ if (!dbPromise) {
+ dbPromise = (async () => {
+ const duckdb = await import('@duckdb/duckdb-wasm');
+ // Load WASM bundles from jsDelivr CDN — no webpack config needed
+ const BUNDLES = duckdb.getJsDelivrBundles();
+ const bundle = await duckdb.selectBundle(BUNDLES);
+
+ const workerUrl = URL.createObjectURL(
+ new Blob([`importScripts("${bundle.mainWorker!}");`], { type: 'text/javascript' }),
+ );
+ const worker = new Worker(workerUrl);
+ const logger = new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING);
+ const db = new duckdb.AsyncDuckDB(logger, worker);
+ await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
+ URL.revokeObjectURL(workerUrl);
+ return db;
+ })();
+ }
+ return dbPromise;
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+function fmtRows(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
+ return String(n);
+}
+
+function fmtBytes(b: number): string {
+ if (b >= 1_000_000) return `${(b / 1_000_000).toFixed(1)} MB`;
+ if (b >= 1_000) return `${(b / 1_000).toFixed(0)} KB`;
+ return `${b} B`;
+}
+
+function resolveParquetUrl(baseUrl: string, file: string): string {
+ const base = baseUrl.replace(/\/metadata\.json$/, '');
+ return `${base}/${file}`;
+}
+
+// Build a WHERE clause from search state.
+// - searchColumn === '__searchable__': search only across the table's defined searchable columns
+// - searchColumn === '__all__': search across every column (slow on large files)
+// - anything else: search a specific single column
+// When a specific column is selected and the term has no wildcards/spaces, uses exact `=`
+// instead of ILIKE so DuckDB can skip row groups via Parquet min/max statistics.
+function buildWhere(
+ allColumns: string[],
+ searchableColumns: string[],
+ searchTerm: string,
+ searchColumn: string,
+): string {
+ if (!searchTerm.trim()) return '';
+ const escaped = searchTerm.replace(/'/g, "''");
+ const isExact = /^[^\s%*?]+$/.test(searchTerm); // no wildcards or spaces
+
+ const ilike = (c: string) => `CAST("${c}" AS VARCHAR) ILIKE '%${escaped}%'`;
+ const exact = (c: string) => `CAST("${c}" AS VARCHAR) = '${escaped}'`;
+
+ if (searchColumn === '__all__') {
+ return `WHERE (${allColumns.map(ilike).join(' OR ')})`;
+ }
+ if (searchColumn === '__searchable__') {
+ const cols = (searchableColumns.length > 0 ? searchableColumns : allColumns.slice(0, 3))
+ .filter((c) => allColumns.includes(c));
+ if (cols.length === 0) return '';
+ return `WHERE (${cols.map(ilike).join(' OR ')})`;
+ }
+ // Single column — use exact match when possible (enables row-group skipping)
+ return `WHERE ${isExact ? exact(searchColumn) : ilike(searchColumn)}`;
+}
+
+// ─── Component ──────────────────────────────────────────────────────────────
+
+export default function GtfsViewerClient({
+ initialUrl,
+ embedded = false,
+}: GtfsViewerClientProps): React.ReactElement {
+ // ── URL / load state
+ const [urlInput, setUrlInput] = useState(initialUrl ?? '');
+ const [loadedUrl, setLoadedUrl] = useState('');
+ const [metadata, setMetadata] = useState(null);
+ const [selectedTable, setSelectedTable] = useState(null);
+ const [parquetUrl, setParquetUrl] = useState(null);
+
+ // ── DuckDB state
+ const [dbReady, setDbReady] = useState(false);
+ const [dbError, setDbError] = useState(null);
+ const connRef = useRef(null);
+
+ // ── Query state
+ const [columns, setColumns] = useState([]);
+ const [rows, setRows] = useState[]>([]);
+ const [totalRows, setTotalRows] = useState(0);
+ const [page, setPage] = useState(0);
+ const [rowsPerPage, setRowsPerPage] = useState(50);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
+ const [searchColumn, setSearchColumn] = useState('__searchable__');
+ const [sortColumn, setSortColumn] = useState(null);
+ const [sortDir, setSortDir] = useState('asc');
+ const [queryLoading, setQueryLoading] = useState(false);
+ const [queryError, setQueryError] = useState(null);
+ const [queryMs, setQueryMs] = useState(null);
+
+ // Debounce search term — wait 500 ms after last keystroke before querying
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 500);
+ return () => clearTimeout(timer);
+ }, [searchTerm]);
+
+ // ── Initialise DuckDB on mount
+ useEffect(() => {
+ getDuckDB()
+ .then(async (db) => {
+ const conn = await db.connect();
+ connRef.current = conn;
+ setDbReady(true);
+ })
+ .catch((e) => setDbError(String(e)));
+ }, []);
+
+ // ── Auto-load when initialUrl is provided and DuckDB is ready
+ useEffect(() => {
+ if (dbReady && initialUrl && !loadedUrl) {
+ setUrlInput(initialUrl);
+ void handleLoad(initialUrl);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dbReady, initialUrl]);
+
+ // ── Run query whenever parquet URL or query params change
+ const runQuery = useCallback(async () => {
+ const conn = connRef.current;
+ if (!conn || !parquetUrl) return;
+ setQueryLoading(true);
+ setQueryError(null);
+ const t0 = performance.now();
+ try {
+ const tableMeta = metadata && selectedTable ? metadata.tables[selectedTable] : null;
+
+ // 1. Get column names if we don't have them yet
+ let cols = columns;
+ if (cols.length === 0) {
+ const schemaResult = await conn.query(
+ `SELECT * FROM read_parquet('${parquetUrl}') LIMIT 0`,
+ );
+ cols = schemaResult.schema.fields.map((f) => f.name);
+ setColumns(cols);
+ }
+
+ const searchCols = (tableMeta?.search_columns ?? []).filter((c) => cols.includes(c));
+ const where = buildWhere(cols, searchCols, debouncedSearchTerm, searchColumn);
+ const orderClause = sortColumn ? `ORDER BY "${sortColumn}" ${sortDir.toUpperCase()}` : '';
+ const hasFilter = debouncedSearchTerm.trim().length > 0;
+
+ let total: number;
+ let resultRows: Record[];
+
+ if (!hasFilter && tableMeta) {
+ // No filter — row count comes from Parquet metadata (zero extra scan)
+ total = tableMeta.row_count;
+ const dataResult = await conn.query(
+ `SELECT * FROM read_parquet('${parquetUrl}')
+ ${orderClause}
+ LIMIT ${rowsPerPage} OFFSET ${page * rowsPerPage}`,
+ );
+ resultRows = dataResult.toArray().map((r) =>
+ Object.fromEntries(cols.map((f) => [f, r[f] ?? null])),
+ );
+ } else {
+ // Filter active — window function gives count + data in a single scan
+ const dataResult = await conn.query(
+ `SELECT *, COUNT(*) OVER() AS __total__
+ FROM read_parquet('${parquetUrl}')
+ ${where}
+ ${orderClause}
+ LIMIT ${rowsPerPage} OFFSET ${page * rowsPerPage}`,
+ );
+ const arr = dataResult.toArray();
+ total = arr.length > 0 ? Number(arr[0].__total__) : 0;
+ resultRows = arr.map((r) => Object.fromEntries(cols.map((f) => [f, r[f] ?? null])));
+ }
+
+ setTotalRows(total);
+ setRows(resultRows);
+ setQueryMs(Math.round(performance.now() - t0));
+ } catch (e) {
+ setQueryError(String(e));
+ } finally {
+ setQueryLoading(false);
+ }
+ }, [parquetUrl, columns, debouncedSearchTerm, searchColumn, sortColumn, sortDir, page, rowsPerPage, metadata, selectedTable]);
+
+ useEffect(() => {
+ if (parquetUrl) runQuery();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [parquetUrl, debouncedSearchTerm, searchColumn, sortColumn, sortDir, page, rowsPerPage]);
+
+ // ── Load URL (metadata.json or direct .parquet)
+ const handleLoad = async (overrideUrl?: string): Promise => {
+ const url = (overrideUrl ?? urlInput).trim();
+ if (!url) return;
+ setQueryError(null);
+ setMetadata(null);
+ setSelectedTable(null);
+ setColumns([]);
+ setRows([]);
+ setTotalRows(0);
+ setPage(0);
+ setSearchTerm('');
+ setDebouncedSearchTerm('');
+ setSortColumn(null);
+ setSearchColumn('__searchable__');
+
+ if (url.endsWith('.parquet') || url.includes('.parquet?')) {
+ setLoadedUrl(url);
+ setParquetUrl(url);
+ } else {
+ // Assume metadata.json
+ try {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const meta: GtfsMetadata = await res.json();
+ setMetadata(meta);
+ setLoadedUrl(url);
+ // Auto-select first table
+ const firstTable = Object.keys(meta.tables)[0];
+ if (firstTable) {
+ setSelectedTable(firstTable);
+ setParquetUrl(resolveParquetUrl(url, meta.tables[firstTable].file));
+ }
+ } catch (e) {
+ setQueryError(`Failed to load: ${String(e)}`);
+ }
+ }
+ };
+
+ const handleTableSelect = (tableName: string): void => {
+ if (!metadata) return;
+ setSelectedTable(tableName);
+ setColumns([]);
+ setRows([]);
+ setTotalRows(0);
+ setPage(0);
+ setSearchTerm('');
+ setDebouncedSearchTerm('');
+ setSortColumn(null);
+ setSearchColumn('__searchable__');
+ setParquetUrl(resolveParquetUrl(loadedUrl, metadata.tables[tableName].file));
+ };
+
+ const handleSort = (col: string): void => {
+ if (sortColumn === col) {
+ setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
+ } else {
+ setSortColumn(col);
+ setSortDir('asc');
+ }
+ setPage(0);
+ };
+
+ const currentTableMeta = metadata && selectedTable ? metadata.tables[selectedTable] : null;
+
+ // ─── Render ────────────────────────────────────────────────────────────────
+ return (
+
+ {/* Header — hidden in embedded mode */}
+ {!embedded && (
+ <>
+
+
+ GTFS Viewer
+
+
+
+
+
+
+
+ Paste a metadata.json URL (from gtfs-to-parquet.sh output) or a
+ direct .parquet URL. Files are queried via HTTP Range requests — only the
+ rows you see are downloaded.
+
+ >
+ )}
+
+ {/* DuckDB status */}
+ {!dbReady && !dbError && (
+ } sx={{ mb: 2 }}>
+ Loading DuckDB-WASM engine (~6 MB, cached after first load)…
+
+ )}
+ {dbError && (
+
+ DuckDB failed to initialise: {dbError}
+
+ )}
+
+ {/* URL input — hidden in embedded mode (URL is pre-set) */}
+ {!embedded && (
+
+
+ setUrlInput(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleLoad()}
+ disabled={!dbReady}
+ />
+
+
+ {metadata && (
+
+ Source: {metadata.source} · Generated: {new Date(metadata.generated_at).toLocaleString()}
+
+ )}
+
+ )}
+
+
+ {/* Table sidebar */}
+ {metadata && (
+
+
+ Tables
+
+
+ {Object.entries(metadata.tables).map(([name, meta]) => (
+ handleTableSelect(name)}
+ sx={{
+ px: 2,
+ py: 1,
+ cursor: 'pointer',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ bgcolor: selectedTable === name ? 'action.selected' : 'transparent',
+ '&:hover': { bgcolor: 'action.hover' },
+ borderLeft: selectedTable === name ? '3px solid' : '3px solid transparent',
+ borderColor: selectedTable === name ? 'primary.main' : 'transparent',
+ }}
+ >
+
+ {name}
+
+ 500_000 ? 'warning' : 'default'}
+ sx={{ fontSize: 10, height: 18 }}
+ />
+
+ ))}
+
+ )}
+
+ {/* Main content */}
+
+ {/* Toolbar */}
+ {parquetUrl && (
+
+
+ {/* Global / column search */}
+
+ Search in
+
+
+
+ { setSearchTerm(e.target.value); setPage(0); }}
+ sx={{ flex: 1 }}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: searchTerm && (
+
+ { setSearchTerm(''); setPage(0); }}>
+
+
+
+ ),
+ }}
+ />
+
+ {/* Stats */}
+
+ {queryMs !== null && (
+
+ )}
+ {currentTableMeta && (
+
+ )}
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Error */}
+ {queryError && (
+ setQueryError(null)}>
+ {queryError}
+
+ )}
+
+ {/* Table */}
+ {parquetUrl && (
+
+
+
+
+
+ {(queryLoading && columns.length === 0
+ ? (currentTableMeta?.columns ?? Array.from({ length: 6 }, (_, i) => `col_${i}`))
+ : columns
+ ).map((col) => (
+
+ handleSort(col)}
+ >
+
+ {col}
+ {(currentTableMeta?.search_columns ?? []).includes(col) && (
+
+
+
+ )}
+
+
+
+ ))}
+
+
+
+ {queryLoading
+ ? Array.from({ length: Math.min(rowsPerPage, 10) }).map((_, i) => (
+
+ {(columns.length > 0 ? columns : Array.from({ length: 6 })).map((_, j) => (
+
+
+
+ ))}
+
+ ))
+ : rows.map((row, i) => (
+
+ {columns.map((col) => (
+
+ {String(row[col] ?? '')}
+
+ ))}
+
+ ))}
+
+
+
+
+ setPage(p)}
+ rowsPerPage={rowsPerPage}
+ onRowsPerPageChange={(e) => { setRowsPerPage(Number(e.target.value)); setPage(0); }}
+ rowsPerPageOptions={[25, 50, 100, 250]}
+ labelDisplayedRows={({ from, to, count }) =>
+ `${from}–${to} of ${fmtRows(count)} rows`
+ }
+ />
+
+ )}
+
+ {/* Empty state */}
+ {!parquetUrl && dbReady && (
+
+ {embedded ? (
+
+ Parquet data not yet available for this dataset. Run{' '}
+ gtfs-to-parquet.sh to generate it.
+
+ ) : (
+ <>
+
+ No file loaded
+
+
+ Generate Parquet files locally:
+
+
+ {`# In mobility-feed-api repo:\n./scripts/gtfs-to-parquet.sh --url "https://storage.googleapis.com/mdb-latest/mdb-10.zip"\n\n# Then serve locally:\ncd ./gtfs_parquet_output && python3 -m http.server 8888\n\n# Paste in the field above:\nhttp://localhost:8888/metadata.json`}
+
+ >
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx
index 202d66d..5564860 100644
--- a/src/app/screens/Feed/FeedView.tsx
+++ b/src/app/screens/Feed/FeedView.tsx
@@ -64,6 +64,11 @@ const PreviousDatasets = dynamic(
{},
);
+const GtfsDataViewer = dynamic(
+ async () =>
+ await import('./components/GtfsDataViewer').then((mod) => mod.default),
+);
+
interface Props {
feed: BasicFeedType;
initialDatasets?: Array;
@@ -421,6 +426,12 @@ export default async function FeedView({
/>
)}
+
+ {feed.data_type === 'gtfs' && latestDataset?.hosted_url != null && (
+
+
+
+ )}
diff --git a/src/app/screens/Feed/components/GtfsDataViewer.tsx b/src/app/screens/Feed/components/GtfsDataViewer.tsx
new file mode 100644
index 0000000..22c7515
--- /dev/null
+++ b/src/app/screens/Feed/components/GtfsDataViewer.tsx
@@ -0,0 +1,144 @@
+'use client';
+
+/**
+ * GtfsDataViewer
+ *
+ * Embeds the GTFS table viewer inside the feed detail page as a lazy-loaded
+ * accordion. DuckDB-WASM is only downloaded when the user intentionally
+ * expands the section — never on initial page load.
+ *
+ * The Parquet metadata URL is derived from the dataset's hosted_url using
+ * the convention established by gtfs-to-parquet.sh:
+ * https://files.mobilitydatabase.org/{feed}/{dataset}/{dataset}.zip
+ * → https://files.mobilitydatabase.org/{feed}/{dataset}/gtfs_parquet/metadata.json
+ */
+
+import React, { useState } from 'react';
+import dynamic from 'next/dynamic';
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Alert,
+ Box,
+ Chip,
+ CircularProgress,
+ Skeleton,
+ Stack,
+ Typography,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import TableChartOutlinedIcon from '@mui/icons-material/TableChartOutlined';
+import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
+
+// next/dynamic with ssr:false prevents Turbopack from statically analysing
+// the DuckDB-WASM package at build time (avoids the Turbopack WASM crash).
+// The component is only loaded client-side when the accordion is expanded.
+const GtfsViewerClient = dynamic(
+ () => import('../../../components/gtfs-viewer/GtfsViewerClient'),
+ {
+ ssr: false,
+ loading: () => (
+
+
+
+
+ Loading DuckDB-WASM engine…
+
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ ),
+ },
+);
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+/**
+ * Derives the Parquet metadata.json URL from a dataset's hosted ZIP URL.
+ * Convention: replace the .zip filename with gtfs_parquet/metadata.json
+ *
+ * Input: https://files.mobilitydatabase.org/mdb-2014/mdb-2014-20250708/mdb-2014-20250708.zip
+ * Output: https://files.mobilitydatabase.org/mdb-2014/mdb-2014-20250708/gtfs_parquet/metadata.json
+ */
+function deriveParquetMetaUrl(hostedUrl: string): string {
+ const lastSlash = hostedUrl.lastIndexOf('/');
+ const base = hostedUrl.substring(0, lastSlash);
+ return `${base}/gtfs_parquet/metadata.json`;
+}
+
+// ─── Props ───────────────────────────────────────────────────────────────────
+
+interface GtfsDataViewerProps {
+ /** The dataset's hosted ZIP URL (from latestDataset.hosted_url) */
+ hostedUrl: string;
+}
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+export default function GtfsDataViewer({ hostedUrl }: GtfsDataViewerProps): React.ReactElement {
+ const [expanded, setExpanded] = useState(false);
+ const parquetMetaUrl = deriveParquetMetaUrl(hostedUrl);
+
+ return (
+ setExpanded(isExpanded)}
+ disableGutters
+ elevation={0}
+ sx={{
+ border: '1px solid',
+ borderColor: 'divider',
+ borderRadius: '6px !important',
+ '&:before': { display: 'none' }, // remove MUI default top divider line
+ bgcolor: 'background.paper',
+ }}
+ >
+ }
+ sx={{
+ px: 3,
+ py: 1,
+ '& .MuiAccordionSummary-content': { alignItems: 'center', gap: 1 },
+ }}
+ >
+
+ Explore Dataset Tables
+
+ {!expanded && (
+
+ Browse stops, routes, trips, stop_times and more
+
+ )}
+
+
+
+ {expanded && (
+ <>
+ }
+ sx={{
+ mx: 2,
+ mt: 2,
+ mb: 0,
+ fontSize: 12,
+ py: 0.5,
+ '& .MuiAlert-message': { py: 0.5 },
+ }}
+ >
+ POC: Queries run entirely in your browser via DuckDB-WASM + HTTP Range
+ requests. Only the rows you see are downloaded — no full file transfer.
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/yarn.lock b/yarn.lock
index 8fee1ed..36a44db 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -488,6 +488,14 @@
enabled "2.0.x"
kuler "^2.0.0"
+"@duckdb/duckdb-wasm@^1.33.1-dev45.0":
+ version "1.33.1-dev45.0"
+ resolved "https://registry.yarnpkg.com/@duckdb/duckdb-wasm/-/duckdb-wasm-1.33.1-dev45.0.tgz#2bc0283d14da0b160a3da1fd0db3195e2ca08874"
+ integrity sha512-ETlrjhiGQzNdaOhpro/Y9u/RCcK+iyuczLy7uOn0kG5Mqlj8C+gTuhBXjs4JpK9ocdUgr3oT8zYYIbUnFD9AYA==
+ dependencies:
+ apache-arrow "^17.0.0"
+ qs "^6.14.1"
+
"@electric-sql/pglite-tools@^0.2.8":
version "0.2.20"
resolved "https://registry.yarnpkg.com/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz#6d1c3d4e2d45a0dd8f91c962f5390feed75a1472"
@@ -3528,6 +3536,13 @@
dependencies:
tslib "^2.8.0"
+"@swc/helpers@^0.5.11":
+ version "0.5.23"
+ resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.23.tgz#19287d0d86d962b111376039a50c792902c9a86a"
+ integrity sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==
+ dependencies:
+ tslib "^2.8.0"
+
"@swc/jest@^0.2.39":
version "0.2.39"
resolved "https://registry.yarnpkg.com/@swc/jest/-/jest-0.2.39.tgz#482bee0adb0726fab1487a4f902a278ec563a6b7"
@@ -3723,6 +3738,16 @@
resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.5.tgz#db9468cb1b1b5a925b8f34822f1669df0c5472f5"
integrity sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==
+"@types/command-line-args@^5.2.3":
+ version "5.2.3"
+ resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.3.tgz#553ce2fd5acf160b448d307649b38ffc60d39639"
+ integrity sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw==
+
+"@types/command-line-usage@^5.0.4":
+ version "5.0.4"
+ resolved "https://registry.yarnpkg.com/@types/command-line-usage/-/command-line-usage-5.0.4.tgz#374e4c62d78fbc5a670a0f36da10235af879a0d5"
+ integrity sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg==
+
"@types/connect@3.4.38":
version "3.4.38"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.38.tgz#5ba7f3bc4fbbdeaff8dded952e5ff2cc53f8d858"
@@ -3917,6 +3942,13 @@
dependencies:
undici-types "~7.16.0"
+"@types/node@^20.13.0":
+ version "20.19.42"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.42.tgz#002109ec605f2a73f46e9677139d8b27a2d88394"
+ integrity sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==
+ dependencies:
+ undici-types "~6.21.0"
+
"@types/node@^22.8.7":
version "22.19.7"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.7.tgz#434094ee1731ae76c16083008590a5835a8c39c1"
@@ -4611,6 +4643,21 @@ anymatch@^3.1.3, anymatch@~3.1.2:
normalize-path "^3.0.0"
picomatch "^2.0.4"
+apache-arrow@^17.0.0:
+ version "17.0.0"
+ resolved "https://registry.yarnpkg.com/apache-arrow/-/apache-arrow-17.0.0.tgz#73d98566c86352c9a0314c03890dbd7211073827"
+ integrity sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q==
+ dependencies:
+ "@swc/helpers" "^0.5.11"
+ "@types/command-line-args" "^5.2.3"
+ "@types/command-line-usage" "^5.0.4"
+ "@types/node" "^20.13.0"
+ command-line-args "^5.2.1"
+ command-line-usage "^7.0.1"
+ flatbuffers "^24.3.25"
+ json-bignum "^0.0.3"
+ tslib "^2.6.2"
+
arch@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11"
@@ -4676,6 +4723,16 @@ arr-union@^3.1.0:
resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
integrity sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==
+array-back@^3.0.1, array-back@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
+ integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+
+array-back@^6.2.2:
+ version "6.2.3"
+ resolved "https://registry.yarnpkg.com/array-back/-/array-back-6.2.3.tgz#d41e67598805e614f23b319b9e5960dfbcd72ae2"
+ integrity sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw==
+
array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b"
@@ -5283,6 +5340,13 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
+chalk-template@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/chalk-template/-/chalk-template-0.4.0.tgz#692c034d0ed62436b9062c1707fadcd0f753204b"
+ integrity sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==
+ dependencies:
+ chalk "^4.1.2"
+
chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2, chalk@~4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
@@ -5527,6 +5591,26 @@ combined-stream@^1.0.8, combined-stream@~1.0.6:
dependencies:
delayed-stream "~1.0.0"
+command-line-args@^5.2.1:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+ integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
+ dependencies:
+ array-back "^3.1.0"
+ find-replace "^3.0.0"
+ lodash.camelcase "^4.3.0"
+ typical "^4.0.0"
+
+command-line-usage@^7.0.1:
+ version "7.0.4"
+ resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-7.0.4.tgz#759449bac39c5410e23513f1f78551b669df1514"
+ integrity sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg==
+ dependencies:
+ array-back "^6.2.2"
+ chalk-template "^0.4.0"
+ table-layout "^4.1.1"
+ typical "^7.3.0"
+
commander@^10.0.0:
version "10.0.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
@@ -7344,6 +7428,13 @@ find-process@2.0.0:
commander "^12.1.0"
loglevel "^1.9.2"
+find-replace@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
+ integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
+ dependencies:
+ array-back "^3.0.1"
+
find-root@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4"
@@ -7509,6 +7600,11 @@ flat-cache@^3.0.4:
keyv "^4.5.3"
rimraf "^3.0.2"
+flatbuffers@^24.3.25:
+ version "24.12.23"
+ resolved "https://registry.yarnpkg.com/flatbuffers/-/flatbuffers-24.12.23.tgz#6eea59d2bcda0c5d59bcacefd6216348b3086883"
+ integrity sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA==
+
flatted@^3.2.9:
version "3.3.3"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
@@ -9367,6 +9463,11 @@ json-bigint@^1.0.0:
dependencies:
bignumber.js "^9.0.0"
+json-bignum@^0.0.3:
+ version "0.0.3"
+ resolved "https://registry.yarnpkg.com/json-bignum/-/json-bignum-0.0.3.tgz#41163b50436c773d82424dbc20ed70db7604b8d7"
+ integrity sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg==
+
json-buffer@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
@@ -12621,6 +12722,14 @@ systeminformation@^5.27.14:
resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.30.6.tgz#c100cb632bbb955fc44ba033f624da22c3a6a5be"
integrity sha512-LEIyK1aEv5P3BhAPW3swdlIyCihxwEq/Gki+kcONieU4PIeRCSLDuGkk0Va/56PSBgjVgEksOM88dmY6YqOyfQ==
+table-layout@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-4.1.1.tgz#0f72965de1a5c0c1419c9ba21cae4e73a2f73a42"
+ integrity sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==
+ dependencies:
+ array-back "^6.2.2"
+ wordwrapjs "^5.1.0"
+
tagged-tag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6"
@@ -12915,7 +13024,7 @@ tsconfig-paths@^3.15.0:
minimist "^1.2.6"
strip-bom "^3.0.0"
-tslib@2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0:
+tslib@2, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.8.0:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
@@ -13101,6 +13210,16 @@ typewise@^1.0.3:
dependencies:
typewise-core "^1.2.0"
+typical@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
+ integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+
+typical@^7.3.0:
+ version "7.3.0"
+ resolved "https://registry.yarnpkg.com/typical/-/typical-7.3.0.tgz#930376be344228709f134613911fa22aa09617a4"
+ integrity sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==
+
unbox-primitive@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2"
@@ -13570,6 +13689,11 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
+wordwrapjs@^5.1.0:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-5.1.1.tgz#bfd1eb426f0f7eec73b7df32cf7df1f618bfb3a9"
+ integrity sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==
+
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"