gtfs-garage-web 0.0.1

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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A source that queries Parquet in the browser, with no server at all.
3
+ *
4
+ * A GTFS dataset is converted to Parquet once - `gtfs-garage --export`, and in
5
+ * production the same conversion behind an API endpoint - and every table
6
+ * becomes one file. DuckDB-WASM reads those over HTTP range requests, so a page
7
+ * opens without downloading, unzipping or converting anything, and nothing has
8
+ * to hold a feed in memory on anyone's behalf.
9
+ *
10
+ * The export writes through the same `ALL_VARCHAR` views the CSVs were read
11
+ * with, so every column is text here exactly as it is server-side and the
12
+ * filters below behave identically.
13
+ */
14
+ import type { FeatureCollection, Filter, GeoJsonKind, ViewerSource, PageResponse, TablesResponse, ValueCount } from "./types";
15
+ /**
16
+ * A WHERE clause with its values inlined.
17
+ *
18
+ * Ported from `core/filters.py`, including the rule that matters most: an empty
19
+ * CSV field loads as NULL, so `= (empty)` has to mean "is empty" or choosing
20
+ * the `(empty)` entry in a picklist would match nothing.
21
+ *
22
+ * DuckDB-WASM's query API takes a string, so values are escaped rather than
23
+ * bound. Every one is a string - the columns are all VARCHAR - and doubling
24
+ * quotes is the whole of the escaping SQL needs for a string literal.
25
+ */
26
+ export declare function buildWhere(known: string[], filters: Filter[]): string;
27
+ export interface ParquetSourceOptions {
28
+ /** Where the `{table}.parquet` files live, without a trailing slash. */
29
+ baseUrl: string;
30
+ /** Shown as the feed's name in the header. */
31
+ name?: string;
32
+ /** Which tables the dataset has. Discovered by probing when not given. */
33
+ tables?: string[];
34
+ }
35
+ export declare class ParquetSource implements ViewerSource {
36
+ private readonly options;
37
+ private connection;
38
+ private terminate;
39
+ private present;
40
+ private startupMs;
41
+ private manifestMs;
42
+ private firstQueryMs;
43
+ constructor(options: ParquetSourceOptions);
44
+ /** Release the worker and the wasm instance. See the viewer's `destroy`. */
45
+ close(): Promise<void>;
46
+ /**
47
+ * The file as an absolute URL.
48
+ *
49
+ * DuckDB runs in a worker and fetches these itself, so it has no page to
50
+ * resolve a relative path against - `/datasets/x` reaches nothing. Callers
51
+ * naturally write a path, so it is resolved here.
52
+ */
53
+ private file;
54
+ /**
55
+ * Start DuckDB on first use.
56
+ *
57
+ * Imported here rather than at the top of the file so that the megabytes of
58
+ * wasm are fetched when a dataset is actually opened, not when the module is.
59
+ */
60
+ private connect;
61
+ /**
62
+ * The table list an exported dataset carries, if it carries one.
63
+ *
64
+ * `gtfs-garage --export` writes a manifest beside the Parquet so a reader
65
+ * need not guess. Without it the only fallback is trying all 32 tables GTFS
66
+ * defines, which for a seven-table feed measured at 123 requests before the
67
+ * first row appeared.
68
+ */
69
+ private fromManifest;
70
+ /**
71
+ * What the dataset cost to open *here*.
72
+ *
73
+ * The server's load report describes a download, an unzip and a conversion.
74
+ * None of that happens in a browser reading Parquet: the cost is starting
75
+ * DuckDB, fetching the manifest, and the first queries. Reporting the
76
+ * conversion's timings instead would describe work this reader did not do.
77
+ */
78
+ private metrics;
79
+ private rows;
80
+ tables(): Promise<TablesResponse>;
81
+ query(table: string, filters: Filter[], page: number, pageSize: number): Promise<PageResponse>;
82
+ distinct(table: string, column: string, limit?: number): Promise<ValueCount[]>;
83
+ geojson(_kind: GeoJsonKind, _ids?: string[]): Promise<FeatureCollection>;
84
+ geojsonStream(_kind: GeoJsonKind, _onMessage: unknown, _signal?: AbortSignal): Promise<void>;
85
+ }
@@ -0,0 +1,35 @@
1
+ /** `GtfsSource` backed by the local Python server. */
2
+ import { type NdjsonMessage } from "./ndjson";
3
+ import type { AppConfig, FeatureCollection, Filter, GeoJsonKind, GtfsSource, LoadProgress, PageResponse, TablesResponse, ValueCount } from "./types";
4
+ export declare class HttpError extends Error {
5
+ readonly status: number;
6
+ constructor(message: string, status: number);
7
+ }
8
+ export declare class RestSource implements GtfsSource {
9
+ private readonly baseUrl;
10
+ constructor(baseUrl?: string);
11
+ config(): Promise<AppConfig>;
12
+ tables(): Promise<TablesResponse>;
13
+ query(table: string, filters: Filter[], page: number, pageSize: number): Promise<PageResponse>;
14
+ distinct(table: string, column: string, limit?: number): Promise<ValueCount[]>;
15
+ geojson(kind: GeoJsonKind, ids?: string[]): Promise<FeatureCollection>;
16
+ loadProgress(): Promise<LoadProgress>;
17
+ /**
18
+ * Stream a whole layer, one batch at a time.
19
+ *
20
+ * Used instead of `geojson` for the full-feed layers: a large feed runs to
21
+ * hundreds of MB, which is neither transferable nor parseable in one piece.
22
+ */
23
+ geojsonStream(kind: GeoJsonKind, onMessage: (message: NdjsonMessage) => void, signal?: AbortSignal): Promise<void>;
24
+ /**
25
+ * Load a feed: an uploaded zip, a chosen folder's files, a local path, or a
26
+ * URL for the server to download.
27
+ */
28
+ load(input: {
29
+ path?: string;
30
+ url?: string;
31
+ file?: File;
32
+ files?: File[];
33
+ name?: string;
34
+ }): Promise<TablesResponse>;
35
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The data contract the UI depends on.
3
+ *
4
+ * These types mirror the pydantic models in src/gtfs_garage/server/models.py,
5
+ * which FastAPI publishes as OpenAPI. Any implementation of `GtfsSource` can
6
+ * drive the UI: `RestSource` talks to the local Python server, and a future
7
+ * DuckDB-WASM implementation could read Parquet over HTTP range requests
8
+ * without the UI knowing the difference.
9
+ */
10
+ import type { NdjsonMessage } from "./ndjson";
11
+ export interface RelatedLink {
12
+ table: string;
13
+ column: string;
14
+ }
15
+ /**
16
+ * What GTFS says an empty value means for a field.
17
+ *
18
+ * `code` is one of the field's own enum codes - "0 or empty - Regularly
19
+ * scheduled pickup" - and is null only where the implied meaning has no code,
20
+ * as for `fare_attributes.transfers`, whose empty value means unlimited
21
+ * transfers.
22
+ */
23
+ export interface ImpliedValue {
24
+ code: string | null;
25
+ label: string;
26
+ }
27
+ /**
28
+ * Whether a feed-scope condition holds in the feed now loaded. `evidence` is
29
+ * what the check counted to decide - "3 agencies" - so the answer can be read
30
+ * back rather than trusted.
31
+ */
32
+ export interface ConditionOutcome {
33
+ holds: boolean;
34
+ evidence: string;
35
+ }
36
+ export interface ColumnInfo {
37
+ name: string;
38
+ /** Set only when the referenced table and column exist in this feed. */
39
+ fk_table: string | null;
40
+ fk_column: string | null;
41
+ /** GTFS defines a small fixed value set, so offer a picklist. */
42
+ enum_like: boolean;
43
+ /** Always present; empty unless this column is its table's primary id. */
44
+ related: RelatedLink[];
45
+ /**
46
+ * What GTFS says the field holds - ID, ENUM, COLOR, LATITUDE, DATE and so on.
47
+ * Null for a column the schema does not describe, such as a producer's own
48
+ * extension column, which renders as text.
49
+ */
50
+ type: string | null;
51
+ /** For an enumeration, its codes and their labels; null otherwise. */
52
+ values: Record<string, string> | null;
53
+ /** "always", "conditional", or null where GTFS makes the field optional. */
54
+ required: string | null;
55
+ /**
56
+ * For a conditionally required field, the condition in words - "Required if
57
+ * route_long_name is empty." Null otherwise.
58
+ */
59
+ condition: string | null;
60
+ /**
61
+ * What decides the condition: "row" where the row settles it, "feed" where
62
+ * the feed as a whole does, "row_context" where it varies row by row and the
63
+ * row does not carry what decides it. Null unless the field is conditional.
64
+ */
65
+ condition_scope: string | null;
66
+ /**
67
+ * The answer for this feed, for a "feed" scope. Null for any other scope, and
68
+ * null when the check could not run.
69
+ */
70
+ condition_outcome: ConditionOutcome | null;
71
+ /** What an empty value implies, where GTFS defines it. Null otherwise. */
72
+ when_empty: ImpliedValue | null;
73
+ /**
74
+ * What a value of this field's type must look like, from the schema's
75
+ * `fieldTypes`. The rule is declared there rather than here so there is one
76
+ * copy of it; all four are null for a type GTFS states no format for.
77
+ */
78
+ pattern: string | null;
79
+ minimum: number | null;
80
+ maximum: number | null;
81
+ /** GTFS's own definition of the type, for when a value does not match it. */
82
+ type_description: string | null;
83
+ }
84
+ /**
85
+ * Why GTFS says this feed should not contain this file. The counterpart to
86
+ * `MissingFile`: that one is absent and needed, this one present and not wanted.
87
+ */
88
+ export interface FileCondition {
89
+ condition: string;
90
+ outcome: ConditionOutcome;
91
+ }
92
+ export interface TableInfo {
93
+ name: string;
94
+ row_count: number;
95
+ columns: ColumnInfo[];
96
+ /** Set only where GTFS forbids this file and the condition holds here. */
97
+ forbidden: FileCondition | null;
98
+ }
99
+ /** What one .txt file cost to open. */
100
+ export interface FileMetrics {
101
+ name: string;
102
+ table: string;
103
+ /** Uncompressed size on disk. */
104
+ bytes: number;
105
+ /** Only for zip sources; a chosen folder has no compressed form. */
106
+ compressed_bytes: number | null;
107
+ /** Declaring the DuckDB view. Views are lazy, so this is near zero. */
108
+ register_ms: number;
109
+ /** Rewriting this table as Parquet; null when opened with --no-parquet. */
110
+ convert_ms: number | null;
111
+ /** What the table occupies once converted, usually far below `bytes`. */
112
+ parquet_bytes: number | null;
113
+ /** The COUNT(*) that actually scans the file - the real read cost. */
114
+ count_ms: number | null;
115
+ row_count: number | null;
116
+ columns: number;
117
+ }
118
+ /** Server-side sizes and timings for the feed currently loaded. */
119
+ export interface LoadMetrics {
120
+ /**
121
+ * Phases this source actually performed, in order.
122
+ *
123
+ * A source that is not a server load describes its own work rather than
124
+ * leaving the fields below to be filled with zeroes: a browser reading
125
+ * Parquet never downloaded or unzipped anything, and saying it took 0 ms to
126
+ * do so would be a claim about work that did not happen.
127
+ */
128
+ phases?: {
129
+ label: string;
130
+ ms: number;
131
+ note?: string;
132
+ }[];
133
+ /**
134
+ * The fields below describe a server load, and are absent for a source that
135
+ * did not perform one. A browser reading Parquet never downloaded, unzipped
136
+ * or converted anything, and a zero here would claim the work took no time
137
+ * rather than that it never happened - which is what `phases` exists for.
138
+ */
139
+ kind?: "path" | "upload" | "folder" | "download";
140
+ /** Downloading or receiving the upload; null for a local path. */
141
+ acquire_ms?: number | null;
142
+ acquire_bytes?: number | null;
143
+ /** Unzipping; null when the source was already-extracted files. */
144
+ extract_ms?: number | null;
145
+ register_ms?: number;
146
+ /** Converting to Parquet; null when opened with --no-parquet. */
147
+ convert_ms?: number | null;
148
+ count_ms?: number;
149
+ /** The phases above added together. */
150
+ total_ms: number;
151
+ /** The zip's size, or the sum of the .txt files in a folder. */
152
+ total_bytes: number;
153
+ /** What the feed occupies to query once converted; null when it was not. */
154
+ stored_bytes: number | null;
155
+ files: FileMetrics[];
156
+ }
157
+ /** Where a running load has got to, polled while the load is in flight. */
158
+ export interface LoadProgress {
159
+ phase: "start" | "download" | "upload" | "extract" | "convert" | "summarise" | "done";
160
+ done: number;
161
+ /** 0 when the total is not knowable, e.g. a download with no Content-Length. */
162
+ total: number;
163
+ detail: string;
164
+ running: boolean;
165
+ }
166
+ /**
167
+ * A file the feed does not have and needs - required, or conditionally required
168
+ * with the condition holding here. Files it merely could have are not listed.
169
+ */
170
+ export interface MissingFile {
171
+ name: string;
172
+ presence: string;
173
+ condition: string | null;
174
+ condition_outcome: ConditionOutcome | null;
175
+ }
176
+ export interface TablesResponse {
177
+ source: string;
178
+ tables: TableInfo[];
179
+ missing: MissingFile[];
180
+ /**
181
+ * What the feed cost to open. Absent for a source with no load to report -
182
+ * a browser reading Parquet did not download, unzip or convert anything.
183
+ */
184
+ metrics?: LoadMetrics;
185
+ }
186
+ /** Row values are strings or null: every column is read as text. */
187
+ export type Row = (string | null)[];
188
+ export interface PageResponse {
189
+ columns: string[];
190
+ rows: Row[];
191
+ total: number;
192
+ page: number;
193
+ page_size: number;
194
+ }
195
+ /** `value: null` means the field is empty in the feed. */
196
+ export interface ValueCount {
197
+ value: string | null;
198
+ count: number;
199
+ }
200
+ export type FilterOperator = "eq" | "ne" | "contains" | "in" | "is_empty" | "is_not_empty";
201
+ export interface Filter {
202
+ column: string;
203
+ op: FilterOperator;
204
+ value?: string;
205
+ }
206
+ export interface AppConfig {
207
+ /** Preset name, raster tile template, or vector style URL. */
208
+ basemap: string;
209
+ version: string;
210
+ }
211
+ export type GeoJsonKind = "stops" | "shapes" | "routes" | "locations";
212
+ export interface FeatureCollection {
213
+ type: "FeatureCollection";
214
+ features: GeoFeature[];
215
+ }
216
+ export interface GeoFeature {
217
+ type: "Feature";
218
+ geometry: {
219
+ type: string;
220
+ coordinates: unknown;
221
+ } | null;
222
+ properties: Record<string, string>;
223
+ }
224
+ /**
225
+ * What a whole viewer needs, which is more than a table needs.
226
+ *
227
+ * `GtfsSource` answers the table. The map additionally streams, because a large
228
+ * feed's shapes arrive over seconds and are drawn as they land rather than all
229
+ * at the end - so a host supplying its own source implements this, not just
230
+ * `GtfsSource`. `config` is optional: it exists on the REST source, where the
231
+ * basemap is a server setting, and a host that passes `basemap` needs neither.
232
+ */
233
+ export interface ViewerSource extends GtfsSource {
234
+ geojsonStream(kind: GeoJsonKind, onMessage: (message: NdjsonMessage) => void, signal?: AbortSignal): Promise<void>;
235
+ config?(): Promise<AppConfig>;
236
+ /**
237
+ * Release whatever the source is holding.
238
+ *
239
+ * A REST source holds nothing; one querying Parquet holds a WebAssembly
240
+ * instance and a worker, and browsers allow only a handful. Whoever created
241
+ * the source calls this - so `mountDataset` closes the source its provider
242
+ * handed it, while `mount` leaves a caller-supplied one alone, because the
243
+ * caller may be reusing it.
244
+ */
245
+ close?(): Promise<void>;
246
+ }
247
+ export interface GtfsSource {
248
+ tables(): Promise<TablesResponse>;
249
+ query(table: string, filters: Filter[], page: number, pageSize: number): Promise<PageResponse>;
250
+ distinct(table: string, column: string, limit?: number): Promise<ValueCount[]>;
251
+ geojson(kind: GeoJsonKind, ids?: string[]): Promise<FeatureCollection>;
252
+ }
@@ -0,0 +1,35 @@
1
+ import type { ColumnInfo, Filter, MissingFile, TableInfo } from "./sources/types";
2
+ /** One browsable view: a table, its filters and which page of it. */
3
+ export interface View {
4
+ table: string;
5
+ filters: Filter[];
6
+ page: number;
7
+ }
8
+ export interface AppState {
9
+ tables: TableInfo[];
10
+ /** Files GTFS says this feed needs and it does not have. */
11
+ missing: MissingFile[];
12
+ columnInfoByName: Record<string, ColumnInfo>;
13
+ view: View;
14
+ pageSize: number;
15
+ /**
16
+ * Which shape draws each route, so a row's route can be highlighted.
17
+ *
18
+ * Just the two ids, not the geometry: holding the whole routes collection to
19
+ * answer one lookup cost hundreds of megabytes on a large feed, and the
20
+ * shape is a cheap fetch when it is actually wanted.
21
+ */
22
+ routeShapes: Map<string, string>;
23
+ mapDataLoaded: boolean;
24
+ /**
25
+ * Show the characters the feed contains instead of formatted values.
26
+ *
27
+ * A display preference, not part of the view: it is deliberately kept out of
28
+ * `View` so that toggling it does not push a history entry, and out of the
29
+ * URL so that Back still means the previous table rather than the previous
30
+ * rendering.
31
+ */
32
+ rawValues: boolean;
33
+ }
34
+ export declare function createState(): AppState;
35
+ export declare function tableInfo(state: AppState, name: string): TableInfo | undefined;
@@ -0,0 +1,35 @@
1
+ /** The overlay for opening a feed: drop a zip, pick a file, or give a path or URL. */
2
+ import type { Dom } from "../dom";
3
+ import type { RestSource } from "../sources/rest";
4
+ import type { TablesResponse } from "../sources/types";
5
+ export declare class FeedLoader {
6
+ private readonly dom;
7
+ private readonly source;
8
+ private readonly onLoaded;
9
+ /** Whether there is a loaded feed to return to when cancelling. */
10
+ private readonly canCancel;
11
+ private busy;
12
+ constructor(dom: Dom, source: RestSource, onLoaded: (tables: TablesResponse, clientMs: number) => void,
13
+ /** Whether there is a loaded feed to return to when cancelling. */
14
+ canCancel: () => boolean);
15
+ open(): void;
16
+ close(): void;
17
+ /**
18
+ * A chosen folder arrives as its individual files. Only the feed's own files
19
+ * are sent: a folder can also hold documentation, archives or editor cruft,
20
+ * and uploading those would be slow for nothing.
21
+ */
22
+ private loadFolder;
23
+ /** A path or URL is sent to a different parameter, so the server knows which. */
24
+ private submitTyped;
25
+ private syncSubmitState;
26
+ /**
27
+ * Poll the server for the load's phase until it is told to stop.
28
+ *
29
+ * Only answerable at all because the load runs in a worker thread; while it
30
+ * sat on the event loop no other request could be served, which is why a big
31
+ * feed looked like a hung page.
32
+ */
33
+ private pollProgress;
34
+ private load;
35
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The load report: what opening the current feed cost, per phase and per file.
3
+ *
4
+ * The numbers come from the server, with one exception - the round trip the
5
+ * browser waited for, which only the browser can see. Formatting and ordering
6
+ * live in exported functions so they can be tested without a document, the
7
+ * same arrangement as `Navigator.parseUrl`.
8
+ */
9
+ import type { Dom } from "../dom";
10
+ import type { LoadMetrics } from "../sources/types";
11
+ export declare function formatBytes(bytes: number): string;
12
+ export declare function formatMs(ms: number): string;
13
+ /** The one-line summary shown in the header. */
14
+ export declare function summaryLine(metrics: LoadMetrics, clientMs?: number): string;
15
+ /** The phases that actually happened, in the order they happened. */
16
+ export declare function phaseRows(metrics: LoadMetrics): Array<[string, string]>;
17
+ export interface FileRow {
18
+ name: string;
19
+ bytes: string;
20
+ /** Share of the feed's uncompressed size, as a whole percentage. */
21
+ share: number;
22
+ compressed: string;
23
+ /** Size as Parquet; an em dash when the feed was not converted. */
24
+ stored: string;
25
+ /**
26
+ * What this file cost: converting it, or counting it when the feed was left
27
+ * as CSV. One number rather than two, because the two the report used to show
28
+ * were measured against different storage - the header read happens on the
29
+ * CSV before conversion and the row count on the Parquet after it - so
30
+ * reading them side by side told you nothing.
31
+ */
32
+ costMs: string;
33
+ rows: string;
34
+ columns: number;
35
+ }
36
+ /**
37
+ * Largest file first: the point of the report is to name the file that
38
+ * dominated the load, and on a real feed that is almost always stop_times.txt.
39
+ */
40
+ export declare function fileRows(metrics: LoadMetrics): FileRow[];
41
+ /**
42
+ * Fills the header summary and the panel it expands into. The panel starts
43
+ * closed: a load report should be there when wanted, not in the way.
44
+ */
45
+ export declare function renderMetrics(dom: Dom, metrics: LoadMetrics, clientMs?: number): void;
46
+ /** Wires the summary button to the panel it opens. Called once, at startup. */
47
+ export declare function initMetricsPanel(dom: Dom): void;
@@ -0,0 +1,18 @@
1
+ /** The filter bar: active filter chips, and the form that adds a new one. */
2
+ import type { Dom } from "../dom";
3
+ import type { Filter, GtfsSource } from "../sources/types";
4
+ import type { AppState } from "../state";
5
+ export declare class FilterBar {
6
+ private readonly dom;
7
+ private readonly state;
8
+ private readonly source;
9
+ private readonly onChange;
10
+ constructor(dom: Dom, state: AppState, source: GtfsSource, onChange: (filters: Filter[]) => void);
11
+ render(): void;
12
+ /**
13
+ * Enumerated columns get a picklist of the values actually present, with
14
+ * counts, so nobody has to guess what a feed uses.
15
+ */
16
+ private updateValueInput;
17
+ private apply;
18
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Where a viewer's back stack lives.
3
+ *
4
+ * The application owns its page, so its stack is the browser's own history and
5
+ * the URL describes the current view - that is what makes a view reloadable and
6
+ * shareable. A viewer embedded in someone else's page must not do that: writing
7
+ * to `history` from a component fights the host's router, and the host may well
8
+ * want the view in its URL under a different scheme, or not at all.
9
+ *
10
+ * So the browser is one implementation of this port rather than an assumption
11
+ * baked into `Navigator`. `memoryHistory` is the other, and is what an embedded
12
+ * viewer uses: the same back behaviour, kept to itself.
13
+ */
14
+ import type { View } from "../state";
15
+ export interface HistoryEntry {
16
+ view: View;
17
+ /** How deep into the stack this entry is, so Back knows when to stop. */
18
+ depth: number;
19
+ /** Which feed the view describes; entries from an older feed are ignored. */
20
+ feed: string;
21
+ }
22
+ export interface HistoryPort {
23
+ push(entry: HistoryEntry): void;
24
+ replace(entry: HistoryEntry): void;
25
+ back(): void;
26
+ /**
27
+ * Called when the user navigates back or forward. Returns a disposer: an
28
+ * embedded viewer is unmounted and remounted by its host, and a listener left
29
+ * on `window` would drive a viewer that no longer exists.
30
+ */
31
+ onPop(handler: (entry: HistoryEntry | null) => void): () => void;
32
+ /** The view this viewer should open on, if the location names one. */
33
+ initialView(): View | null;
34
+ }
35
+ /** `?table=stops&page=2&filters=[…]` - the shape a shared link has. */
36
+ export declare function urlFor(view: View): string;
37
+ /** Pure counterpart of reading `location.search`, so it is testable. */
38
+ export declare function parseUrl(search: string): View | null;
39
+ /** The back stack of an application that owns its page. */
40
+ export declare function browserHistory(): HistoryPort;
41
+ /**
42
+ * A back stack the viewer keeps to itself, for an embedded one.
43
+ *
44
+ * Nothing here touches the URL or `window`, which is the point: the host stays
45
+ * in charge of its own address bar, and can still mirror the view there by
46
+ * listening for view changes if it wants to.
47
+ */
48
+ export declare function memoryHistory(initial?: View | null): HistoryPort;
@@ -0,0 +1,7 @@
1
+ /** Turns a load-progress reading into the line shown while a feed opens. */
2
+ import type { LoadProgress } from "../sources/types";
3
+ /**
4
+ * `description` is what the user asked for, used until the server reports a
5
+ * phase - a load has to travel to the server before it can describe itself.
6
+ */
7
+ export declare function progressLine(progress: LoadProgress | null, description: string): string;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * View navigation.
3
+ *
4
+ * Every view change - table switch, filter change, page turn - goes through
5
+ * `apply`, so the in-app Back button and whatever the host uses for back are
6
+ * the same mechanism. Where the back stack lives is `HistoryPort`'s business:
7
+ * the browser's own history for the application, a private stack for a viewer
8
+ * embedded in someone else's page.
9
+ */
10
+ import type { Dom } from "../dom";
11
+ import type { Filter, TableInfo } from "../sources/types";
12
+ import { type AppState, type View } from "../state";
13
+ import { parseUrl, urlFor, type HistoryPort } from "./history";
14
+ export type ViewListener = (view: View) => void;
15
+ /**
16
+ * Where to land when a feed opens: what the feed says about itself, then who
17
+ * runs it, then what it runs.
18
+ */
19
+ export declare const PREFERRED_TABLES: string[];
20
+ /**
21
+ * Choose the first view of a feed.
22
+ *
23
+ * `restoreFrom` is only passed on page load, so a reloaded or shared URL comes
24
+ * back to the same place. Opening a different feed deliberately ignores it: its
25
+ * filters describe the previous feed's data and would either match nothing or,
26
+ * worse, silently hide rows in a table that happens to share a name.
27
+ */
28
+ export declare function initialView(tables: TableInfo[], restoreFrom?: View | null): View | null;
29
+ export declare class Navigator {
30
+ private readonly dom;
31
+ private readonly history;
32
+ private readonly state;
33
+ private readonly onChange;
34
+ private depth;
35
+ private feed;
36
+ /** Undoes the history subscription taken in the constructor. */
37
+ private readonly stopListening;
38
+ constructor(dom: Dom, history: HistoryPort, state: AppState, onChange: ViewListener);
39
+ /** Stop responding to the host's history. See `mount`'s `destroy`. */
40
+ destroy(): void;
41
+ private entryFor;
42
+ /** Re-exported so callers need not know where the URL shape is defined. */
43
+ static urlFor: typeof urlFor;
44
+ static parseUrl: typeof parseUrl;
45
+ /** The view named by the current location, for an app that owns its page. */
46
+ fromLocation(): View | null;
47
+ current(): View;
48
+ /**
49
+ * Start a fresh history for a newly opened feed.
50
+ *
51
+ * `feed` identifies it, so entries left in the browser's history by an
52
+ * earlier feed can be recognised and ignored.
53
+ */
54
+ reset(view: View, feed: string): void;
55
+ go(view: View): void;
56
+ /** Step back one view. Public so an embedding host can offer its own control. */
57
+ back(): void;
58
+ selectTable(table: string, filters?: Filter[]): void;
59
+ private apply;
60
+ }
@@ -0,0 +1,21 @@
1
+ import type { Dom } from "../dom";
2
+ import type { AppState } from "../state";
3
+ import type { MissingFile, TableInfo } from "../sources/types";
4
+ /**
5
+ * Why a file the feed lacks is wanted.
6
+ *
7
+ * A required file needs no explanation. A conditionally required one does: the
8
+ * condition, and what makes it apply to this feed, because "calendar.txt is
9
+ * missing" is not actionable while "required unless calendar_dates.txt defines
10
+ * every service date - calendar_dates is absent" is.
11
+ */
12
+ export declare function describeMissing(file: MissingFile): string;
13
+ /**
14
+ * Why GTFS says this feed should not have this file.
15
+ *
16
+ * The other direction from `describeMissing`. Empty for the ordinary case, so
17
+ * a caller can use the result as the test of whether to say anything.
18
+ */
19
+ export declare function describeForbidden(table: TableInfo): string;
20
+ /** The list of the feed's files, with row counts, and any it is missing. */
21
+ export declare function renderSidebar(dom: Dom, state: AppState, onSelect: (table: string) => void): void;