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.
- package/README.md +86 -0
- package/dist/dataset.d.ts +73 -0
- package/dist/dataset.js +64 -0
- package/dist/dom.d.ts +24 -0
- package/dist/gtfs-garage-web.css +1 -0
- package/dist/map/basemap.d.ts +23 -0
- package/dist/map/geo.d.ts +4 -0
- package/dist/map/layers.d.ts +28 -0
- package/dist/map/map.d.ts +60 -0
- package/dist/map/redraw.d.ts +29 -0
- package/dist/map-part.d.ts +10 -0
- package/dist/map.js +334 -0
- package/dist/markup.d.ts +24 -0
- package/dist/mount-Bpr3Nw4_.js +1064 -0
- package/dist/mount-dataset.d.ts +29 -0
- package/dist/mount.d.ts +79 -0
- package/dist/mount.js +7 -0
- package/dist/parquet.js +263 -0
- package/dist/react.d.ts +31 -0
- package/dist/react.js +36 -0
- package/dist/schema.d.ts +41 -0
- package/dist/sources/fixtures.d.ts +20 -0
- package/dist/sources/ndjson.d.ts +36 -0
- package/dist/sources/parquet.d.ts +85 -0
- package/dist/sources/rest.d.ts +35 -0
- package/dist/sources/types.d.ts +252 -0
- package/dist/state.d.ts +35 -0
- package/dist/ui/feed-loader.d.ts +35 -0
- package/dist/ui/feed-metrics.d.ts +47 -0
- package/dist/ui/filters.d.ts +18 -0
- package/dist/ui/history.d.ts +48 -0
- package/dist/ui/load-progress.d.ts +7 -0
- package/dist/ui/navigation.d.ts +60 -0
- package/dist/ui/sidebar.d.ts +21 -0
- package/dist/ui/table.d.ts +97 -0
- package/dist/ui/values.d.ts +61 -0
- package/package.json +94 -0
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
var B = Object.defineProperty;
|
|
2
|
+
var z = (n, e, t) => e in n ? B(n, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : n[e] = t;
|
|
3
|
+
var b = (n, e, t) => z(n, typeof e != "symbol" ? e + "" : e, t);
|
|
4
|
+
function j(n) {
|
|
5
|
+
return {
|
|
6
|
+
el(e) {
|
|
7
|
+
const t = n.querySelector(`[data-el="${CSS.escape(e)}"]`);
|
|
8
|
+
if (!t) throw new Error(`Missing element ${e}`);
|
|
9
|
+
return t;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function I(n, e, t) {
|
|
14
|
+
const s = document.createElement("button");
|
|
15
|
+
return s.textContent = n, s.title = e, s.addEventListener("click", t), s;
|
|
16
|
+
}
|
|
17
|
+
const G = { load: !0, report: !0, map: !0 }, Y = (n) => `
|
|
18
|
+
<header>
|
|
19
|
+
<h1>GTFS Garage</h1>
|
|
20
|
+
<div data-el="source-label">No feed loaded</div>
|
|
21
|
+
${n.report ? '<button data-el="metrics-btn" title="What this feed cost to open" hidden></button>' : ""}
|
|
22
|
+
${n.map ? '<button data-el="toggle-map-btn">Hide map</button>' : ""}
|
|
23
|
+
${n.load ? '<button data-el="open-load-btn">Load feed</button>' : ""}
|
|
24
|
+
</header>`, J = () => `
|
|
25
|
+
<div data-el="metrics-panel" class="hidden">
|
|
26
|
+
<div data-el="metrics-header">
|
|
27
|
+
<strong>Load report</strong>
|
|
28
|
+
<button data-el="metrics-close-btn" title="Close">×</button>
|
|
29
|
+
</div>
|
|
30
|
+
<div data-el="metrics-body"></div>
|
|
31
|
+
</div>`, K = (n) => `
|
|
32
|
+
<div data-el="layout">
|
|
33
|
+
<div data-el="sidebar"></div>
|
|
34
|
+
<div data-el="content">
|
|
35
|
+
<div data-el="table-pane">
|
|
36
|
+
<div data-el="filter-bar">
|
|
37
|
+
<button data-el="back-btn" title="Back to the previous view" disabled>← Back</button>
|
|
38
|
+
<button data-el="add-filter-btn">+ Filter</button>
|
|
39
|
+
<label data-el="raw-values" class="raw-toggle"
|
|
40
|
+
title="Show the characters the feed contains, without formatting">
|
|
41
|
+
<input data-el="raw-values-input" type="checkbox" /> Raw values
|
|
42
|
+
</label>
|
|
43
|
+
</div>
|
|
44
|
+
<div data-el="add-filter-form">
|
|
45
|
+
<select data-el="filter-column"></select>
|
|
46
|
+
<select data-el="filter-op">
|
|
47
|
+
<option value="eq">is</option>
|
|
48
|
+
<option value="ne">is not</option>
|
|
49
|
+
<option value="contains">contains</option>
|
|
50
|
+
<option value="in">is one of (comma sep.)</option>
|
|
51
|
+
<option value="is_empty">is empty</option>
|
|
52
|
+
<option value="is_not_empty">is not empty</option>
|
|
53
|
+
</select>
|
|
54
|
+
<input data-el="filter-value" type="text" placeholder="value" />
|
|
55
|
+
<select data-el="filter-value-picker" style="display:none"></select>
|
|
56
|
+
<button data-el="filter-apply-btn" class="primary">Add</button>
|
|
57
|
+
<button data-el="filter-cancel-btn">Cancel</button>
|
|
58
|
+
</div>
|
|
59
|
+
<div data-el="table-scroll">
|
|
60
|
+
<div data-el="empty-state">Load a GTFS feed to start browsing.</div>
|
|
61
|
+
</div>
|
|
62
|
+
<div data-el="pager" style="display:none">
|
|
63
|
+
<button data-el="prev-page">« Prev</button>
|
|
64
|
+
<span data-el="page-info"></span>
|
|
65
|
+
<button data-el="next-page">Next »</button>
|
|
66
|
+
<span class="spacer"></span>
|
|
67
|
+
<span data-el="row-count"></span>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
${n.map ? `<div data-el="map-pane">
|
|
71
|
+
<div data-el="map"></div>
|
|
72
|
+
<!-- Filled in while a layer streams; hidden the rest of the time. -->
|
|
73
|
+
<div data-el="map-progress" hidden></div>
|
|
74
|
+
</div>` : ""}
|
|
75
|
+
</div>
|
|
76
|
+
</div>`, W = () => `
|
|
77
|
+
<div data-el="load-overlay" class="hidden">
|
|
78
|
+
<div data-el="load-panel">
|
|
79
|
+
<h2>Load a GTFS feed</h2>
|
|
80
|
+
<div data-el="dropzone">Drop a GTFS .zip here, or click to choose one</div>
|
|
81
|
+
<input data-el="file-input" type="file" accept=".zip" style="display:none" />
|
|
82
|
+
<!-- webkitdirectory turns this into a folder chooser. -->
|
|
83
|
+
<input data-el="folder-input" type="file" webkitdirectory directory multiple style="display:none" />
|
|
84
|
+
<div data-el="pick-row">
|
|
85
|
+
<button data-el="pick-file-btn">Choose a .zip…</button>
|
|
86
|
+
<button data-el="pick-folder-btn">Choose a folder…</button>
|
|
87
|
+
</div>
|
|
88
|
+
<div class="or">or</div>
|
|
89
|
+
<label class="field-label">Local path or URL</label>
|
|
90
|
+
<input
|
|
91
|
+
data-el="path-input"
|
|
92
|
+
type="text"
|
|
93
|
+
autocomplete="off"
|
|
94
|
+
spellcheck="false"
|
|
95
|
+
placeholder="/path/to/feed.zip, /path/to/extracted/folder, or https://example.org/gtfs.zip"
|
|
96
|
+
/>
|
|
97
|
+
<div data-el="load-actions">
|
|
98
|
+
<button data-el="load-path-btn" class="primary" disabled>Load</button>
|
|
99
|
+
<button data-el="load-cancel-btn">Cancel</button>
|
|
100
|
+
</div>
|
|
101
|
+
<div data-el="load-status"></div>
|
|
102
|
+
<div data-el="load-error"></div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>`;
|
|
105
|
+
function X(n, e = {}) {
|
|
106
|
+
const t = { ...G, ...e };
|
|
107
|
+
n.classList.add("gtfs-viewer"), n.innerHTML = [
|
|
108
|
+
Y(t),
|
|
109
|
+
t.report ? J() : "",
|
|
110
|
+
K(t),
|
|
111
|
+
t.load ? W() : ""
|
|
112
|
+
].join(`
|
|
113
|
+
`);
|
|
114
|
+
}
|
|
115
|
+
function Z(n) {
|
|
116
|
+
const e = n.split(`
|
|
117
|
+
`), t = e.pop() ?? "";
|
|
118
|
+
return { lines: e.filter((s) => s.length > 0), rest: t };
|
|
119
|
+
}
|
|
120
|
+
async function Q(n, e) {
|
|
121
|
+
const t = n.getReader(), s = new TextDecoder();
|
|
122
|
+
let o = "";
|
|
123
|
+
for (; ; ) {
|
|
124
|
+
const { done: l, value: i } = await t.read();
|
|
125
|
+
if (l) break;
|
|
126
|
+
o += s.decode(i, { stream: !0 });
|
|
127
|
+
const { lines: r, rest: h } = Z(o);
|
|
128
|
+
o = h;
|
|
129
|
+
for (const d of r)
|
|
130
|
+
try {
|
|
131
|
+
e(JSON.parse(d));
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const a = o.trim();
|
|
136
|
+
if (a)
|
|
137
|
+
try {
|
|
138
|
+
e(JSON.parse(a));
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
class D extends Error {
|
|
143
|
+
constructor(e, t) {
|
|
144
|
+
super(e), this.status = t;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function m(n, e) {
|
|
148
|
+
const t = await fetch(n, e);
|
|
149
|
+
if (!t.ok) {
|
|
150
|
+
const s = await t.json().catch(() => ({}));
|
|
151
|
+
throw new D(
|
|
152
|
+
s.detail ?? `${t.status} ${t.statusText}`,
|
|
153
|
+
t.status
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return t.json();
|
|
157
|
+
}
|
|
158
|
+
const ee = {
|
|
159
|
+
stops: "stop_ids",
|
|
160
|
+
shapes: "shape_ids",
|
|
161
|
+
routes: "shape_ids",
|
|
162
|
+
locations: "location_ids"
|
|
163
|
+
};
|
|
164
|
+
class te {
|
|
165
|
+
constructor(e = "") {
|
|
166
|
+
this.baseUrl = e;
|
|
167
|
+
}
|
|
168
|
+
config() {
|
|
169
|
+
return m(`${this.baseUrl}/api/config`);
|
|
170
|
+
}
|
|
171
|
+
tables() {
|
|
172
|
+
return m(`${this.baseUrl}/api/tables`);
|
|
173
|
+
}
|
|
174
|
+
query(e, t, s, o) {
|
|
175
|
+
const a = new URLSearchParams({
|
|
176
|
+
page: String(s),
|
|
177
|
+
page_size: String(o),
|
|
178
|
+
filters: JSON.stringify(t)
|
|
179
|
+
});
|
|
180
|
+
return m(
|
|
181
|
+
`${this.baseUrl}/api/table/${encodeURIComponent(e)}?${a}`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
async distinct(e, t, s = 100) {
|
|
185
|
+
const { values: o } = await m(
|
|
186
|
+
`${this.baseUrl}/api/table/${encodeURIComponent(e)}/distinct/${encodeURIComponent(t)}?limit=${s}`
|
|
187
|
+
);
|
|
188
|
+
return o;
|
|
189
|
+
}
|
|
190
|
+
geojson(e, t) {
|
|
191
|
+
const s = new URLSearchParams();
|
|
192
|
+
for (const a of t ?? []) s.append(ee[e], a);
|
|
193
|
+
const o = s.toString();
|
|
194
|
+
return m(
|
|
195
|
+
`${this.baseUrl}/api/geojson/${e}${o ? `?${o}` : ""}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
loadProgress() {
|
|
199
|
+
return m(`${this.baseUrl}/api/load/progress`);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Stream a whole layer, one batch at a time.
|
|
203
|
+
*
|
|
204
|
+
* Used instead of `geojson` for the full-feed layers: a large feed runs to
|
|
205
|
+
* hundreds of MB, which is neither transferable nor parseable in one piece.
|
|
206
|
+
*/
|
|
207
|
+
async geojsonStream(e, t, s) {
|
|
208
|
+
const o = await fetch(`${this.baseUrl}/api/geojson/${e}`, { signal: s });
|
|
209
|
+
if (!o.ok) {
|
|
210
|
+
const a = await o.json().catch(() => ({}));
|
|
211
|
+
throw new D(
|
|
212
|
+
a.detail ?? `${o.status} ${o.statusText}`,
|
|
213
|
+
o.status
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (!o.body) throw new Error("This browser cannot stream responses.");
|
|
217
|
+
await Q(o.body, t);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Load a feed: an uploaded zip, a chosen folder's files, a local path, or a
|
|
221
|
+
* URL for the server to download.
|
|
222
|
+
*/
|
|
223
|
+
load(e) {
|
|
224
|
+
var s;
|
|
225
|
+
if ((s = e.files) != null && s.length) {
|
|
226
|
+
const o = new FormData();
|
|
227
|
+
for (const l of e.files) o.append("files", l);
|
|
228
|
+
const a = e.name ? `?name=${encodeURIComponent(e.name)}` : "";
|
|
229
|
+
return m(`${this.baseUrl}/api/load${a}`, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
body: o
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (e.file) {
|
|
235
|
+
const o = new FormData();
|
|
236
|
+
return o.append("file", e.file), m(`${this.baseUrl}/api/load`, {
|
|
237
|
+
method: "POST",
|
|
238
|
+
body: o
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const t = new URLSearchParams();
|
|
242
|
+
return e.url ? t.set("url", e.url) : t.set("path", e.path ?? ""), m(`${this.baseUrl}/api/load?${t}`, { method: "POST" });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function ne() {
|
|
246
|
+
return {
|
|
247
|
+
tables: [],
|
|
248
|
+
missing: [],
|
|
249
|
+
columnInfoByName: {},
|
|
250
|
+
view: { table: "", filters: [], page: 1 },
|
|
251
|
+
pageSize: 100,
|
|
252
|
+
routeShapes: /* @__PURE__ */ new Map(),
|
|
253
|
+
mapDataLoaded: !1,
|
|
254
|
+
rawValues: !1
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function oe(n, e) {
|
|
258
|
+
return n.tables.find((t) => t.name === e);
|
|
259
|
+
}
|
|
260
|
+
const M = ["B", "KB", "MB", "GB", "TB"], se = {
|
|
261
|
+
path: "Local path",
|
|
262
|
+
upload: "Upload",
|
|
263
|
+
folder: "Folder upload",
|
|
264
|
+
download: "Download"
|
|
265
|
+
};
|
|
266
|
+
function f(n) {
|
|
267
|
+
let e = n, t = 0;
|
|
268
|
+
for (; e >= 1024 && t < M.length - 1; )
|
|
269
|
+
e /= 1024, t += 1;
|
|
270
|
+
const s = t === 0 ? 0 : e < 10 ? 1 : 0;
|
|
271
|
+
return `${e.toFixed(s)} ${M[t]}`;
|
|
272
|
+
}
|
|
273
|
+
function p(n) {
|
|
274
|
+
return n >= 1e3 ? `${(n / 1e3).toFixed(1)} s` : n >= 10 ? `${Math.round(n)} ms` : `${n.toFixed(1)} ms`;
|
|
275
|
+
}
|
|
276
|
+
function H(n) {
|
|
277
|
+
return n.files.reduce((e, t) => e + t.bytes, 0);
|
|
278
|
+
}
|
|
279
|
+
function ae(n, e) {
|
|
280
|
+
const t = [
|
|
281
|
+
`${n.files.length} ${n.files.length === 1 ? "file" : "files"}`,
|
|
282
|
+
f(H(n)),
|
|
283
|
+
// Only a server load is time spent on a server. A source that described its
|
|
284
|
+
// own phases spent that time here, in this browser.
|
|
285
|
+
n.phases ? p(n.total_ms) : `${p(n.total_ms)} server`
|
|
286
|
+
];
|
|
287
|
+
return e !== void 0 && t.push(`${p(e)} round trip`), t.join(" · ");
|
|
288
|
+
}
|
|
289
|
+
function ie(n) {
|
|
290
|
+
if (n.phases)
|
|
291
|
+
return n.phases.map(({ label: t, ms: s, note: o }) => [t, o ? `${p(s)} · ${o}` : p(s)]);
|
|
292
|
+
const e = [];
|
|
293
|
+
if (n.acquire_ms != null && n.kind) {
|
|
294
|
+
const t = n.acquire_bytes == null ? "" : ` · ${f(n.acquire_bytes)}`;
|
|
295
|
+
e.push([se[n.kind], `${p(n.acquire_ms)}${t}`]);
|
|
296
|
+
}
|
|
297
|
+
if (n.extract_ms != null && e.push(["Unzip", `${p(n.extract_ms)} · ${f(n.total_bytes)} archive`]), n.register_ms != null && e.push(["Read headers", p(n.register_ms)]), n.convert_ms != null) {
|
|
298
|
+
const t = n.stored_bytes === null ? "" : ` · ${f(n.stored_bytes)} stored`;
|
|
299
|
+
e.push(["Convert to Parquet", `${p(n.convert_ms)}${t}`]);
|
|
300
|
+
}
|
|
301
|
+
return n.count_ms != null && e.push(["Count rows", p(n.count_ms)]), e;
|
|
302
|
+
}
|
|
303
|
+
function le(n) {
|
|
304
|
+
const e = H(n);
|
|
305
|
+
return [...n.files].sort((t, s) => s.bytes - t.bytes).map((t) => ({
|
|
306
|
+
name: t.name,
|
|
307
|
+
bytes: f(t.bytes),
|
|
308
|
+
share: e === 0 ? 0 : Math.round(t.bytes / e * 100),
|
|
309
|
+
compressed: t.compressed_bytes === null ? "—" : f(t.compressed_bytes),
|
|
310
|
+
stored: t.parquet_bytes === null ? "—" : f(t.parquet_bytes),
|
|
311
|
+
costMs: re(n, t),
|
|
312
|
+
rows: t.row_count === null ? "—" : t.row_count.toLocaleString(),
|
|
313
|
+
columns: t.columns
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
function O(n) {
|
|
317
|
+
return n.convert_ms !== null;
|
|
318
|
+
}
|
|
319
|
+
function re(n, e) {
|
|
320
|
+
const t = O(n) ? e.convert_ms : e.count_ms;
|
|
321
|
+
return t === null ? "—" : p(t);
|
|
322
|
+
}
|
|
323
|
+
function de(n) {
|
|
324
|
+
const e = n.phases ? null : O(n) ? {
|
|
325
|
+
label: "Convert",
|
|
326
|
+
title: "Time to rewrite this file as Parquet, which is what every query then reads.",
|
|
327
|
+
value: (t) => t.costMs
|
|
328
|
+
} : {
|
|
329
|
+
label: "Count",
|
|
330
|
+
title: "Time to read this file end to end to count its rows.",
|
|
331
|
+
value: (t) => t.costMs
|
|
332
|
+
};
|
|
333
|
+
return [
|
|
334
|
+
{ label: "File", value: (t) => t.name },
|
|
335
|
+
{ label: "Size", title: "Uncompressed, and its share of the feed", value: (t) => `${t.bytes} (${t.share}%)` },
|
|
336
|
+
{ label: "Zipped", title: "Size inside the archive", value: (t) => t.compressed },
|
|
337
|
+
{ label: "Stored", title: "Size as Parquet, which is what queries read", value: (t) => t.stored },
|
|
338
|
+
{ label: "Rows", value: (t) => t.rows },
|
|
339
|
+
{ label: "Cols", value: (t) => String(t.columns) },
|
|
340
|
+
...e ? [e] : []
|
|
341
|
+
];
|
|
342
|
+
}
|
|
343
|
+
function ce(n) {
|
|
344
|
+
const e = document.createElement("dl");
|
|
345
|
+
e.dataset.el = "metrics-phases";
|
|
346
|
+
for (const [t, s] of ie(n)) {
|
|
347
|
+
const o = document.createElement("dt");
|
|
348
|
+
o.textContent = t;
|
|
349
|
+
const a = document.createElement("dd");
|
|
350
|
+
a.textContent = s, e.append(o, a);
|
|
351
|
+
}
|
|
352
|
+
return e;
|
|
353
|
+
}
|
|
354
|
+
function he(n) {
|
|
355
|
+
const e = de(n), t = document.createElement("table"), s = document.createElement("tr");
|
|
356
|
+
for (const o of e) {
|
|
357
|
+
const a = document.createElement("th");
|
|
358
|
+
a.textContent = o.label, o.title && (a.title = o.title), s.appendChild(a);
|
|
359
|
+
}
|
|
360
|
+
t.appendChild(s);
|
|
361
|
+
for (const o of le(n)) {
|
|
362
|
+
const a = document.createElement("tr");
|
|
363
|
+
for (const l of e) {
|
|
364
|
+
const i = document.createElement("td");
|
|
365
|
+
i.textContent = l.value(o), a.appendChild(i);
|
|
366
|
+
}
|
|
367
|
+
t.appendChild(a);
|
|
368
|
+
}
|
|
369
|
+
return t;
|
|
370
|
+
}
|
|
371
|
+
function ue(n, e, t) {
|
|
372
|
+
const s = n.el("metrics-btn");
|
|
373
|
+
s.textContent = ae(e, t), s.hidden = !1;
|
|
374
|
+
const o = n.el("metrics-body");
|
|
375
|
+
o.innerHTML = "", o.append(ce(e), he(e)), n.el("metrics-panel").classList.add("hidden");
|
|
376
|
+
}
|
|
377
|
+
function pe(n) {
|
|
378
|
+
const e = n.el("metrics-panel");
|
|
379
|
+
n.el("metrics-btn").addEventListener("click", () => e.classList.toggle("hidden")), n.el("metrics-close-btn").addEventListener("click", () => e.classList.add("hidden"));
|
|
380
|
+
}
|
|
381
|
+
const me = {
|
|
382
|
+
start: "Loading",
|
|
383
|
+
download: "Downloading",
|
|
384
|
+
upload: "Receiving",
|
|
385
|
+
extract: "Unzipping",
|
|
386
|
+
convert: "Converting",
|
|
387
|
+
summarise: "Counting rows",
|
|
388
|
+
done: "Loading"
|
|
389
|
+
};
|
|
390
|
+
function fe(n, e) {
|
|
391
|
+
if (!n || !n.running) return `Loading ${e}…`;
|
|
392
|
+
if (n.phase === "start") return `Loading ${e}…`;
|
|
393
|
+
const t = me[n.phase] ?? "Loading";
|
|
394
|
+
if (n.phase === "summarise") return `${t}…`;
|
|
395
|
+
if (n.phase === "download") {
|
|
396
|
+
const o = f(n.done);
|
|
397
|
+
return n.total > 0 ? `${t} ${o} of ${f(n.total)}` : `${t} ${o}`;
|
|
398
|
+
}
|
|
399
|
+
const s = n.detail ? ` ${n.detail}` : "";
|
|
400
|
+
return n.total > 0 ? `${t}${s} (${n.done} of ${n.total})` : `${t}${s}…`;
|
|
401
|
+
}
|
|
402
|
+
const be = /^https?:\/\//i, ye = 400;
|
|
403
|
+
class ge {
|
|
404
|
+
constructor(e, t, s, o) {
|
|
405
|
+
b(this, "busy", !1);
|
|
406
|
+
this.dom = e, this.source = t, this.onLoaded = s, this.canCancel = o, this.dom.el("open-load-btn").addEventListener("click", () => this.open()), this.dom.el("load-cancel-btn").addEventListener("click", () => this.close());
|
|
407
|
+
const a = this.dom.el("path-input");
|
|
408
|
+
a.addEventListener("input", () => this.syncSubmitState()), a.addEventListener("keydown", (h) => {
|
|
409
|
+
h.key === "Enter" && this.submitTyped(), h.key === "Escape" && this.canCancel() && this.close();
|
|
410
|
+
}), this.dom.el("load-path-btn").addEventListener("click", () => this.submitTyped());
|
|
411
|
+
const l = this.dom.el("dropzone"), i = this.dom.el("file-input"), r = this.dom.el("folder-input");
|
|
412
|
+
l.addEventListener("click", () => i.click()), this.dom.el("pick-file-btn").addEventListener("click", () => i.click()), this.dom.el("pick-folder-btn").addEventListener("click", () => r.click()), r.addEventListener("change", (h) => {
|
|
413
|
+
const d = Array.from(h.target.files ?? []);
|
|
414
|
+
d.length && this.loadFolder(d);
|
|
415
|
+
});
|
|
416
|
+
for (const h of ["dragenter", "dragover"])
|
|
417
|
+
l.addEventListener(h, (d) => {
|
|
418
|
+
d.preventDefault(), l.classList.add("drag");
|
|
419
|
+
});
|
|
420
|
+
for (const h of ["dragleave", "drop"])
|
|
421
|
+
l.addEventListener(h, (d) => {
|
|
422
|
+
d.preventDefault(), l.classList.remove("drag");
|
|
423
|
+
});
|
|
424
|
+
l.addEventListener("drop", (h) => {
|
|
425
|
+
var g;
|
|
426
|
+
const d = (g = h.dataTransfer) == null ? void 0 : g.files[0];
|
|
427
|
+
d && this.load(() => this.source.load({ file: d }), d.name);
|
|
428
|
+
}), this.dom.el("file-input").addEventListener("change", (h) => {
|
|
429
|
+
var g;
|
|
430
|
+
const d = (g = h.target.files) == null ? void 0 : g[0];
|
|
431
|
+
d && this.load(() => this.source.load({ file: d }), d.name);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
open() {
|
|
435
|
+
this.dom.el("load-overlay").classList.remove("hidden"), this.dom.el("load-error").textContent = "", this.syncSubmitState(), this.dom.el("path-input").focus();
|
|
436
|
+
}
|
|
437
|
+
close() {
|
|
438
|
+
this.dom.el("load-overlay").classList.add("hidden");
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* A chosen folder arrives as its individual files. Only the feed's own files
|
|
442
|
+
* are sent: a folder can also hold documentation, archives or editor cruft,
|
|
443
|
+
* and uploading those would be slow for nothing.
|
|
444
|
+
*/
|
|
445
|
+
async loadFolder(e) {
|
|
446
|
+
const t = e.filter((a) => /\.(txt|geojson)$/i.test(a.name));
|
|
447
|
+
if (t.length === 0) {
|
|
448
|
+
this.dom.el("load-error").textContent = "That folder has no GTFS .txt files in it.";
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const s = t[0].webkitRelativePath, o = (s == null ? void 0 : s.split("/")[0]) || "chosen folder";
|
|
452
|
+
await this.load(() => this.source.load({ files: t, name: o }), o);
|
|
453
|
+
}
|
|
454
|
+
/** A path or URL is sent to a different parameter, so the server knows which. */
|
|
455
|
+
submitTyped() {
|
|
456
|
+
const e = this.dom.el("path-input").value.trim();
|
|
457
|
+
if (!e || this.busy) return;
|
|
458
|
+
const t = be.test(e) ? () => this.source.load({ url: e }) : () => this.source.load({ path: e });
|
|
459
|
+
this.load(t, e);
|
|
460
|
+
}
|
|
461
|
+
syncSubmitState() {
|
|
462
|
+
const e = this.dom.el("path-input").value.trim() === "";
|
|
463
|
+
this.dom.el("load-path-btn").disabled = e || this.busy, this.dom.el("load-cancel-btn").hidden = !this.canCancel();
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Poll the server for the load's phase until it is told to stop.
|
|
467
|
+
*
|
|
468
|
+
* Only answerable at all because the load runs in a worker thread; while it
|
|
469
|
+
* sat on the event loop no other request could be served, which is why a big
|
|
470
|
+
* feed looked like a hung page.
|
|
471
|
+
*/
|
|
472
|
+
pollProgress(e) {
|
|
473
|
+
let t = !1;
|
|
474
|
+
return (async () => {
|
|
475
|
+
for (; !t; ) {
|
|
476
|
+
try {
|
|
477
|
+
const o = await this.source.loadProgress();
|
|
478
|
+
t || (this.dom.el("load-status").textContent = fe(o, e));
|
|
479
|
+
} catch {
|
|
480
|
+
}
|
|
481
|
+
await new Promise((o) => window.setTimeout(o, ye));
|
|
482
|
+
}
|
|
483
|
+
})(), () => {
|
|
484
|
+
t = !0;
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
async load(e, t) {
|
|
488
|
+
this.busy = !0, this.syncSubmitState(), this.dom.el("load-error").textContent = "", this.dom.el("load-status").textContent = `Loading ${t}…`;
|
|
489
|
+
const s = this.pollProgress(t);
|
|
490
|
+
try {
|
|
491
|
+
const o = performance.now(), a = await e();
|
|
492
|
+
this.onLoaded(a, performance.now() - o), this.close();
|
|
493
|
+
} catch (o) {
|
|
494
|
+
this.dom.el("load-error").textContent = o.message;
|
|
495
|
+
} finally {
|
|
496
|
+
s(), this.busy = !1, this.dom.el("load-status").textContent = "", this.syncSubmitState();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const ve = {
|
|
501
|
+
eq: "=",
|
|
502
|
+
ne: "!=",
|
|
503
|
+
contains: "contains",
|
|
504
|
+
in: "in",
|
|
505
|
+
is_empty: "is empty",
|
|
506
|
+
is_not_empty: "is not empty"
|
|
507
|
+
}, q = ["is_empty", "is_not_empty"];
|
|
508
|
+
class we {
|
|
509
|
+
constructor(e, t, s, o) {
|
|
510
|
+
this.dom = e, this.state = t, this.source = s, this.onChange = o, this.dom.el("add-filter-btn").addEventListener("click", () => {
|
|
511
|
+
this.dom.el("add-filter-form").classList.add("open"), this.updateValueInput();
|
|
512
|
+
}), this.dom.el("filter-cancel-btn").addEventListener(
|
|
513
|
+
"click",
|
|
514
|
+
() => this.dom.el("add-filter-form").classList.remove("open")
|
|
515
|
+
), this.dom.el("filter-column").addEventListener("change", () => void this.updateValueInput()), this.dom.el("filter-op").addEventListener("change", () => void this.updateValueInput()), this.dom.el("filter-apply-btn").addEventListener("click", () => this.apply());
|
|
516
|
+
}
|
|
517
|
+
render() {
|
|
518
|
+
const e = this.dom.el("filter-bar");
|
|
519
|
+
e.querySelectorAll(".chip").forEach((t) => t.remove()), this.state.view.filters.forEach((t, s) => {
|
|
520
|
+
const o = document.createElement("span");
|
|
521
|
+
o.className = "chip";
|
|
522
|
+
const a = t.value === "" || t.value == null ? "(empty)" : t.value, l = !q.includes(t.op), i = document.createElement("span");
|
|
523
|
+
i.textContent = `${t.column} ${ve[t.op]} ${l ? a : ""}`.trim();
|
|
524
|
+
const r = document.createElement("button");
|
|
525
|
+
r.textContent = "×", r.addEventListener(
|
|
526
|
+
"click",
|
|
527
|
+
() => this.onChange(this.state.view.filters.filter((h, d) => d !== s))
|
|
528
|
+
), o.append(i, r), e.appendChild(o);
|
|
529
|
+
}), this.dom.el("filter-column").innerHTML = Object.keys(this.state.columnInfoByName).map((t) => `<option value="${t}">${t}</option>`).join("");
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Enumerated columns get a picklist of the values actually present, with
|
|
533
|
+
* counts, so nobody has to guess what a feed uses.
|
|
534
|
+
*/
|
|
535
|
+
async updateValueInput() {
|
|
536
|
+
const e = this.dom.el("filter-column").value, t = this.dom.el("filter-op").value, s = this.dom.el("filter-value"), o = this.dom.el("filter-value-picker");
|
|
537
|
+
if (q.includes(t)) {
|
|
538
|
+
s.style.display = "none", o.style.display = "none";
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const a = this.state.columnInfoByName[e];
|
|
542
|
+
if (!((a == null ? void 0 : a.enum_like) && t !== "contains" && t !== "in")) {
|
|
543
|
+
s.style.display = "inline-block", o.style.display = "none";
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
s.style.display = "none", o.style.display = "inline-block", o.innerHTML = "<option value=''>Loading...</option>";
|
|
547
|
+
const i = await this.source.distinct(this.state.view.table, e, 100);
|
|
548
|
+
o.innerHTML = i.map(({ value: r, count: h }) => `<option value="${r ?? ""}">${r === "" || r === null ? "(empty)" : r} (${h})</option>`).join("");
|
|
549
|
+
}
|
|
550
|
+
apply() {
|
|
551
|
+
const e = this.dom.el("filter-column").value;
|
|
552
|
+
if (!e) return;
|
|
553
|
+
const t = this.dom.el("filter-op").value, s = this.dom.el("filter-value-picker"), o = this.dom.el("filter-value"), a = s.style.display !== "none" ? s.value : o.value;
|
|
554
|
+
this.dom.el("add-filter-form").classList.remove("open"), o.value = "", this.onChange([...this.state.view.filters, { column: e, op: t, value: a }]);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function _(n) {
|
|
558
|
+
const e = new URLSearchParams({ table: n.table, page: String(n.page || 1) });
|
|
559
|
+
return n.filters.length && e.set("filters", JSON.stringify(n.filters)), `?${e}`;
|
|
560
|
+
}
|
|
561
|
+
function V(n) {
|
|
562
|
+
const e = new URLSearchParams(n), t = e.get("table");
|
|
563
|
+
if (!t) return null;
|
|
564
|
+
let s = [];
|
|
565
|
+
try {
|
|
566
|
+
const o = JSON.parse(e.get("filters") || "[]");
|
|
567
|
+
s = Array.isArray(o) ? o : [];
|
|
568
|
+
} catch {
|
|
569
|
+
s = [];
|
|
570
|
+
}
|
|
571
|
+
return { table: t, filters: s, page: Number(e.get("page")) || 1 };
|
|
572
|
+
}
|
|
573
|
+
function Be() {
|
|
574
|
+
const n = (e) => ({
|
|
575
|
+
gtfsView: { ...e.view, depth: e.depth },
|
|
576
|
+
feed: e.feed
|
|
577
|
+
});
|
|
578
|
+
return {
|
|
579
|
+
push: (e) => history.pushState(n(e), "", _(e.view)),
|
|
580
|
+
replace: (e) => history.replaceState(n(e), "", _(e.view)),
|
|
581
|
+
back: () => history.back(),
|
|
582
|
+
onPop(e) {
|
|
583
|
+
const t = (s) => {
|
|
584
|
+
const o = s.state;
|
|
585
|
+
if (!(o != null && o.gtfsView)) return e(null);
|
|
586
|
+
const { depth: a, ...l } = o.gtfsView;
|
|
587
|
+
e({ view: l, depth: a ?? 0, feed: o.feed });
|
|
588
|
+
};
|
|
589
|
+
return window.addEventListener("popstate", t), () => window.removeEventListener("popstate", t);
|
|
590
|
+
},
|
|
591
|
+
initialView: () => V(location.search)
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function Le(n = null) {
|
|
595
|
+
const e = [];
|
|
596
|
+
let t = () => {
|
|
597
|
+
};
|
|
598
|
+
return {
|
|
599
|
+
push: (s) => void e.push(s),
|
|
600
|
+
replace(s) {
|
|
601
|
+
e.length = 0, e.push(s);
|
|
602
|
+
},
|
|
603
|
+
back() {
|
|
604
|
+
e.length <= 1 || (e.pop(), t(e[e.length - 1]));
|
|
605
|
+
},
|
|
606
|
+
onPop(s) {
|
|
607
|
+
return t = s, () => {
|
|
608
|
+
t = () => {
|
|
609
|
+
};
|
|
610
|
+
};
|
|
611
|
+
},
|
|
612
|
+
initialView: () => n
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const Ee = ["feed_info", "agency", "routes"];
|
|
616
|
+
function $e(n, e) {
|
|
617
|
+
if (e && n.some((s) => s.name === e.table))
|
|
618
|
+
return e;
|
|
619
|
+
const t = Ee.map((s) => n.find((o) => o.name === s)).find(Boolean) ?? n[0];
|
|
620
|
+
return t ? { table: t.name, filters: [], page: 1 } : null;
|
|
621
|
+
}
|
|
622
|
+
class x {
|
|
623
|
+
constructor(e, t, s, o) {
|
|
624
|
+
b(this, "depth", 0);
|
|
625
|
+
b(this, "feed", "");
|
|
626
|
+
/** Undoes the history subscription taken in the constructor. */
|
|
627
|
+
b(this, "stopListening");
|
|
628
|
+
this.dom = e, this.history = t, this.state = s, this.onChange = o, this.stopListening = this.history.onPop((a) => {
|
|
629
|
+
if (a) {
|
|
630
|
+
if (a.feed !== this.feed) {
|
|
631
|
+
this.history.replace(this.entryFor(this.state.view));
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
this.depth = a.depth, this.apply(a.view, !1);
|
|
635
|
+
}
|
|
636
|
+
}), this.dom.el("back-btn").addEventListener("click", () => this.back());
|
|
637
|
+
}
|
|
638
|
+
/** Stop responding to the host's history. See `mount`'s `destroy`. */
|
|
639
|
+
destroy() {
|
|
640
|
+
this.stopListening();
|
|
641
|
+
}
|
|
642
|
+
entryFor(e) {
|
|
643
|
+
return { view: e, depth: this.depth, feed: this.feed };
|
|
644
|
+
}
|
|
645
|
+
/** The view named by the current location, for an app that owns its page. */
|
|
646
|
+
fromLocation() {
|
|
647
|
+
return this.history.initialView();
|
|
648
|
+
}
|
|
649
|
+
current() {
|
|
650
|
+
return this.state.view;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Start a fresh history for a newly opened feed.
|
|
654
|
+
*
|
|
655
|
+
* `feed` identifies it, so entries left in the browser's history by an
|
|
656
|
+
* earlier feed can be recognised and ignored.
|
|
657
|
+
*/
|
|
658
|
+
reset(e, t) {
|
|
659
|
+
this.depth = 0, this.feed = t, this.history.replace(this.entryFor(e)), this.apply(e, !1);
|
|
660
|
+
}
|
|
661
|
+
go(e) {
|
|
662
|
+
this.apply(e, !0);
|
|
663
|
+
}
|
|
664
|
+
/** Step back one view. Public so an embedding host can offer its own control. */
|
|
665
|
+
back() {
|
|
666
|
+
this.history.back();
|
|
667
|
+
}
|
|
668
|
+
selectTable(e, t = []) {
|
|
669
|
+
this.go({ table: e, filters: t, page: 1 });
|
|
670
|
+
}
|
|
671
|
+
apply(e, t) {
|
|
672
|
+
const s = oe(this.state, e.table);
|
|
673
|
+
s && (this.state.view = {
|
|
674
|
+
table: e.table,
|
|
675
|
+
filters: structuredClone(e.filters ?? []),
|
|
676
|
+
page: e.page || 1
|
|
677
|
+
}, this.state.columnInfoByName = Object.fromEntries(s.columns.map((o) => [o.name, o])), t && (this.depth += 1, this.history.push(this.entryFor(this.state.view))), this.dom.el("back-btn").disabled = this.depth <= 0, this.onChange(this.state.view));
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/** Re-exported so callers need not know where the URL shape is defined. */
|
|
681
|
+
b(x, "urlFor", _), b(x, "parseUrl", V);
|
|
682
|
+
function Ce(n) {
|
|
683
|
+
if (n.presence !== "conditional") return "Required. This feed does not have it.";
|
|
684
|
+
const e = n.condition ?? "Conditionally required.", t = n.condition_outcome ? ` Required here: ${n.condition_outcome.evidence}.` : "";
|
|
685
|
+
return `${e}${t}`;
|
|
686
|
+
}
|
|
687
|
+
function ke(n) {
|
|
688
|
+
const e = n.forbidden;
|
|
689
|
+
return e ? `${e.condition} Forbidden here: ${e.outcome.evidence}.` : "";
|
|
690
|
+
}
|
|
691
|
+
function F(n, e, t) {
|
|
692
|
+
const s = n.el("sidebar");
|
|
693
|
+
s.innerHTML = "";
|
|
694
|
+
for (const o of e.tables) {
|
|
695
|
+
const a = document.createElement("div");
|
|
696
|
+
a.className = "table-tab" + (o.name === e.view.table ? " active" : "");
|
|
697
|
+
const l = document.createElement("span");
|
|
698
|
+
l.textContent = o.name;
|
|
699
|
+
const i = document.createElement("span");
|
|
700
|
+
i.className = "count", i.textContent = o.row_count.toLocaleString();
|
|
701
|
+
const r = ke(o);
|
|
702
|
+
r && (a.classList.add("forbidden"), a.title = r, i.textContent = "forbidden"), a.append(l, i), a.addEventListener("click", () => t(o.name)), s.appendChild(a);
|
|
703
|
+
}
|
|
704
|
+
for (const o of e.missing) {
|
|
705
|
+
const a = document.createElement("div");
|
|
706
|
+
a.className = "table-tab missing", a.title = Ce(o), a.dataset.el = "missing-file";
|
|
707
|
+
const l = document.createElement("span");
|
|
708
|
+
l.textContent = o.name;
|
|
709
|
+
const i = document.createElement("span");
|
|
710
|
+
i.className = "count", i.textContent = "missing", a.append(l, i), s.appendChild(a);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const Se = /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]), y = (n) => ({ text: n, kind: "plain" }), v = (n, e) => ({
|
|
714
|
+
text: n,
|
|
715
|
+
kind: "plain",
|
|
716
|
+
malformed: e
|
|
717
|
+
});
|
|
718
|
+
function A(n, e, t) {
|
|
719
|
+
const s = t && !n.includes(":") ? `${t}${n}` : n;
|
|
720
|
+
try {
|
|
721
|
+
const o = new URL(s);
|
|
722
|
+
return Se.has(o.protocol) ? { text: n, kind: "link", href: o.href } : v(n, e);
|
|
723
|
+
} catch {
|
|
724
|
+
return v(n, e);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function _e(n, e, t) {
|
|
728
|
+
const s = t.minimum ?? void 0, o = t.maximum ?? void 0;
|
|
729
|
+
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(n.trim()))
|
|
730
|
+
return v(n, e);
|
|
731
|
+
const a = Number(n);
|
|
732
|
+
return !Number.isFinite(a) || s !== void 0 && a < s || o !== void 0 && a > o ? v(n, e) : { text: n, kind: "numeric" };
|
|
733
|
+
}
|
|
734
|
+
function xe(n, e) {
|
|
735
|
+
const [t, s, o] = [n.slice(0, 4), n.slice(4, 6), n.slice(6, 8)].map(Number), a = new Date(t, s - 1, o);
|
|
736
|
+
return a.getMonth() !== s - 1 || a.getDate() !== o ? v(n, e) : y(a.toLocaleDateString(void 0, { year: "numeric", month: "short", day: "numeric" }));
|
|
737
|
+
}
|
|
738
|
+
function Re(n) {
|
|
739
|
+
return { text: n, kind: "color", swatch: `#${n}` };
|
|
740
|
+
}
|
|
741
|
+
function Te(n, e) {
|
|
742
|
+
const t = e == null ? void 0 : e[n];
|
|
743
|
+
return y(!t || t === n ? n : `${t} (${n})`);
|
|
744
|
+
}
|
|
745
|
+
const Ne = {
|
|
746
|
+
COLOR: "six hex digits, without a leading #",
|
|
747
|
+
DATE: "a date as YYYYMMDD",
|
|
748
|
+
EMAIL: "an email address",
|
|
749
|
+
URL: "a URL",
|
|
750
|
+
LATITUDE: "a latitude",
|
|
751
|
+
LONGITUDE: "a longitude",
|
|
752
|
+
INTEGER: "a number",
|
|
753
|
+
FLOAT: "a number",
|
|
754
|
+
CURRENCY_AMOUNT: "a number"
|
|
755
|
+
};
|
|
756
|
+
function E(n) {
|
|
757
|
+
const e = Ne[n.type ?? ""] ?? n.type_description ?? "a value GTFS recognises", { minimum: t, maximum: s } = n;
|
|
758
|
+
return t !== null && s !== null ? `${e} between ${t} and ${s}` : e;
|
|
759
|
+
}
|
|
760
|
+
const k = /* @__PURE__ */ new Map();
|
|
761
|
+
function Pe(n) {
|
|
762
|
+
if (!k.has(n))
|
|
763
|
+
try {
|
|
764
|
+
k.set(n, new RegExp(n));
|
|
765
|
+
} catch {
|
|
766
|
+
k.set(n, null);
|
|
767
|
+
}
|
|
768
|
+
return k.get(n) ?? null;
|
|
769
|
+
}
|
|
770
|
+
function Ue(n, e) {
|
|
771
|
+
if (e.pattern) {
|
|
772
|
+
const t = Pe(e.pattern);
|
|
773
|
+
if (t && !t.test(n)) return v(n, E(e));
|
|
774
|
+
}
|
|
775
|
+
switch (e.type) {
|
|
776
|
+
case "ENUM":
|
|
777
|
+
return Te(n, e.values);
|
|
778
|
+
case "COLOR":
|
|
779
|
+
return Re(n);
|
|
780
|
+
case "URL":
|
|
781
|
+
return A(n, E(e));
|
|
782
|
+
case "EMAIL":
|
|
783
|
+
return A(n, E(e), "mailto:");
|
|
784
|
+
case "DATE":
|
|
785
|
+
return xe(n, E(e));
|
|
786
|
+
case "LATITUDE":
|
|
787
|
+
case "LONGITUDE":
|
|
788
|
+
case "INTEGER":
|
|
789
|
+
case "FLOAT":
|
|
790
|
+
case "CURRENCY_AMOUNT":
|
|
791
|
+
return _e(n, E(e), e);
|
|
792
|
+
// TIME has a pattern and so is checked above, but is deliberately not
|
|
793
|
+
// reformatted: GTFS times legitimately exceed 24:00:00 for trips running
|
|
794
|
+
// past midnight, and a formatter that "corrected" 25:30:00 would be worse
|
|
795
|
+
// than none.
|
|
796
|
+
default:
|
|
797
|
+
return y(n);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function Ie(n) {
|
|
801
|
+
const e = n.when_empty;
|
|
802
|
+
return e ? {
|
|
803
|
+
text: e.code ? `${e.label} (${e.code})` : e.label,
|
|
804
|
+
kind: "plain",
|
|
805
|
+
implied: e.code ? `Empty. GTFS implies ${e.code} - ${e.label}.` : `Empty. GTFS implies: ${e.label}`
|
|
806
|
+
} : y("");
|
|
807
|
+
}
|
|
808
|
+
function Me(n, e, t = !1) {
|
|
809
|
+
if (n == null)
|
|
810
|
+
return !t && e ? Ie(e) : y("");
|
|
811
|
+
if (t || !e) return y(n);
|
|
812
|
+
try {
|
|
813
|
+
return Ue(n, e);
|
|
814
|
+
} catch {
|
|
815
|
+
return y(n);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
function qe(n) {
|
|
819
|
+
if (n.required === "always") return "Required";
|
|
820
|
+
if (n.required !== "conditional") return "";
|
|
821
|
+
const e = n.condition_outcome;
|
|
822
|
+
let t;
|
|
823
|
+
return e ? t = `${e.holds ? "Required" : "Not required"} in this feed (${e.evidence})` : n.condition_scope === "row_context" ? t = "Conditionally required, row by row" : t = "Conditionally required", n.condition ? `${t} - ${n.condition}` : t;
|
|
824
|
+
}
|
|
825
|
+
function Fe(n) {
|
|
826
|
+
if (!n) return "";
|
|
827
|
+
const e = [];
|
|
828
|
+
n.type && e.push(n.type);
|
|
829
|
+
const t = qe(n);
|
|
830
|
+
if (t && e.push(t), n.when_empty) {
|
|
831
|
+
const { code: s, label: o } = n.when_empty;
|
|
832
|
+
e.push(s ? `empty implies ${s} - ${o}` : `empty implies: ${o}`);
|
|
833
|
+
}
|
|
834
|
+
return e.join(" · ");
|
|
835
|
+
}
|
|
836
|
+
function Ae(n) {
|
|
837
|
+
const e = /* @__PURE__ */ new Map();
|
|
838
|
+
for (const t of n) e.set(t.table, (e.get(t.table) ?? 0) + 1);
|
|
839
|
+
return n.map((t) => ({
|
|
840
|
+
link: t,
|
|
841
|
+
label: (e.get(t.table) ?? 0) > 1 ? `▸ ${t.table} ${t.column}` : `▸ ${t.table}`,
|
|
842
|
+
title: `Rows in ${t.table} whose ${t.column} is this`
|
|
843
|
+
}));
|
|
844
|
+
}
|
|
845
|
+
function De(n, e, t) {
|
|
846
|
+
const s = [], { onHighlightStop: o, onHighlightShape: a, onHighlightRoute: l, onHighlightLocation: i } = t;
|
|
847
|
+
if (n.stop_id && o) {
|
|
848
|
+
const r = n.stop_id;
|
|
849
|
+
s.push({ label: "📍", title: "Show this stop on the map", run: () => o(r) });
|
|
850
|
+
}
|
|
851
|
+
if (n.shape_id && a) {
|
|
852
|
+
const r = n.shape_id;
|
|
853
|
+
s.push({ label: "🧭", title: "Show this shape on the map", run: () => a(r) });
|
|
854
|
+
}
|
|
855
|
+
if (n.route_id && e.hasRouteShapes && l) {
|
|
856
|
+
const r = n.route_id;
|
|
857
|
+
s.push({ label: "🚌", title: "Show this route on the map", run: () => l(r) });
|
|
858
|
+
}
|
|
859
|
+
if (n.id && e.table === "locations" && i) {
|
|
860
|
+
const r = n.id;
|
|
861
|
+
s.push({ label: "🗺️", title: "Show this zone on the map", run: () => i(r) });
|
|
862
|
+
}
|
|
863
|
+
return s;
|
|
864
|
+
}
|
|
865
|
+
class He {
|
|
866
|
+
constructor(e, t, s) {
|
|
867
|
+
/**
|
|
868
|
+
* The page currently on screen, so that a display change can redraw without
|
|
869
|
+
* asking the server again - on a large feed a refetch to toggle formatting
|
|
870
|
+
* would be a needless round trip over millions of rows.
|
|
871
|
+
*/
|
|
872
|
+
b(this, "lastPage", null);
|
|
873
|
+
this.dom = e, this.state = t, this.callbacks = s, this.dom.el("prev-page").addEventListener("click", () => {
|
|
874
|
+
this.state.view.page > 1 && this.callbacks.onPage(this.state.view.page - 1);
|
|
875
|
+
}), this.dom.el("next-page").addEventListener(
|
|
876
|
+
"click",
|
|
877
|
+
() => this.callbacks.onPage(this.state.view.page + 1)
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
/** Draw the current page again, picking up a changed display preference. */
|
|
881
|
+
redraw() {
|
|
882
|
+
this.lastPage && this.render(this.lastPage);
|
|
883
|
+
}
|
|
884
|
+
showMessage(e) {
|
|
885
|
+
this.lastPage = null;
|
|
886
|
+
const t = this.dom.el("table-scroll");
|
|
887
|
+
t.innerHTML = "";
|
|
888
|
+
const s = document.createElement("div");
|
|
889
|
+
s.dataset.el = "empty-state", s.textContent = e, t.appendChild(s), this.dom.el("pager").style.display = "none";
|
|
890
|
+
}
|
|
891
|
+
render(e) {
|
|
892
|
+
this.lastPage = e;
|
|
893
|
+
const t = document.createElement("table"), s = document.createElement("thead"), o = document.createElement("tr");
|
|
894
|
+
this.hasActions && o.appendChild(document.createElement("th"));
|
|
895
|
+
for (const i of e.columns) {
|
|
896
|
+
const r = document.createElement("th");
|
|
897
|
+
r.textContent = i;
|
|
898
|
+
const h = Fe(this.state.columnInfoByName[i]);
|
|
899
|
+
h && (r.title = h), o.appendChild(r);
|
|
900
|
+
}
|
|
901
|
+
s.appendChild(o), t.appendChild(s);
|
|
902
|
+
const a = document.createElement("tbody");
|
|
903
|
+
for (const i of e.rows)
|
|
904
|
+
a.appendChild(this.buildRow(e.columns, i));
|
|
905
|
+
t.appendChild(a);
|
|
906
|
+
const l = this.dom.el("table-scroll");
|
|
907
|
+
l.innerHTML = "", l.appendChild(t), this.renderPager(e);
|
|
908
|
+
}
|
|
909
|
+
buildRow(e, t) {
|
|
910
|
+
const s = document.createElement("tr"), o = {};
|
|
911
|
+
if (e.forEach((a, l) => o[a] = t[l]), this.hasActions) {
|
|
912
|
+
const a = document.createElement("td");
|
|
913
|
+
a.appendChild(this.buildRowActions(o)), s.appendChild(a);
|
|
914
|
+
}
|
|
915
|
+
return e.forEach((a, l) => {
|
|
916
|
+
s.appendChild(this.buildCell(a, t[l]));
|
|
917
|
+
}), s;
|
|
918
|
+
}
|
|
919
|
+
buildCell(e, t) {
|
|
920
|
+
const s = document.createElement("td"), o = this.state.columnInfoByName[e];
|
|
921
|
+
if (o != null && o.fk_table && t) {
|
|
922
|
+
const a = document.createElement("a");
|
|
923
|
+
a.href = "#", a.className = "fk-link", a.textContent = t, a.addEventListener("click", (l) => {
|
|
924
|
+
l.preventDefault(), this.callbacks.onNavigate(o.fk_table, [
|
|
925
|
+
{ column: o.fk_column, op: "eq", value: t }
|
|
926
|
+
]);
|
|
927
|
+
}), s.appendChild(a);
|
|
928
|
+
} else
|
|
929
|
+
this.renderValue(s, t, o);
|
|
930
|
+
if (o != null && o.related.length && t) {
|
|
931
|
+
const a = document.createElement("div");
|
|
932
|
+
a.className = "related-row";
|
|
933
|
+
for (const { link: l, label: i, title: r } of Ae(o.related)) {
|
|
934
|
+
const h = I(
|
|
935
|
+
i,
|
|
936
|
+
r,
|
|
937
|
+
() => this.callbacks.onNavigate(l.table, [{ column: l.column, op: "eq", value: t }])
|
|
938
|
+
);
|
|
939
|
+
h.className = "related-btn", a.appendChild(h);
|
|
940
|
+
}
|
|
941
|
+
s.appendChild(a);
|
|
942
|
+
}
|
|
943
|
+
return s;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Draw what `renderValue` decided.
|
|
947
|
+
*
|
|
948
|
+
* Every path here writes through `textContent`, never `innerHTML`, so a feed
|
|
949
|
+
* cannot inject markup - and an `href` is only ever set from a URL that
|
|
950
|
+
* module already restricted to http, https and mailto.
|
|
951
|
+
*/
|
|
952
|
+
renderValue(e, t, s) {
|
|
953
|
+
const o = Me(t, s, this.state.rawValues);
|
|
954
|
+
if (o.kind === "link" && o.href) {
|
|
955
|
+
const a = document.createElement("a");
|
|
956
|
+
a.href = o.href, a.textContent = o.text, a.className = "value-link", a.rel = "noopener noreferrer", a.target = "_blank", e.appendChild(a);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (o.kind === "color" && o.swatch) {
|
|
960
|
+
const a = document.createElement("span");
|
|
961
|
+
a.className = "value-swatch", a.style.backgroundColor = o.swatch, e.append(a, document.createTextNode(o.text));
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
o.implied && (e.classList.add("value-implied"), e.title = o.implied), o.kind === "numeric" && e.classList.add("value-numeric"), o.malformed && (e.classList.add("value-malformed"), e.title = `Expected ${o.malformed}`), e.textContent = o.text;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Whether any row could ever carry an action.
|
|
968
|
+
*
|
|
969
|
+
* Decides the column, not the button: with no map there is nothing to put in
|
|
970
|
+
* it, and an always-empty leading column is worse than no column at all.
|
|
971
|
+
*/
|
|
972
|
+
get hasActions() {
|
|
973
|
+
const { onHighlightStop: e, onHighlightShape: t, onHighlightRoute: s, onHighlightLocation: o } = this.callbacks;
|
|
974
|
+
return !!(e || t || s || o);
|
|
975
|
+
}
|
|
976
|
+
buildRowActions(e) {
|
|
977
|
+
const t = document.createElement("span"), s = { table: this.state.view.table, hasRouteShapes: this.state.routeShapes.size > 0 };
|
|
978
|
+
for (const o of De(e, s, this.callbacks)) {
|
|
979
|
+
const a = I(o.label, o.title, o.run);
|
|
980
|
+
a.className = "map-btn", t.appendChild(a);
|
|
981
|
+
}
|
|
982
|
+
return t;
|
|
983
|
+
}
|
|
984
|
+
renderPager(e) {
|
|
985
|
+
this.dom.el("pager").style.display = "flex";
|
|
986
|
+
const t = Math.max(1, Math.ceil(e.total / e.page_size));
|
|
987
|
+
this.dom.el("page-info").textContent = `Page ${e.page} / ${t}`, this.dom.el("row-count").textContent = `${e.total.toLocaleString()} rows`, this.dom.el("prev-page").disabled = e.page <= 1, this.dom.el("next-page").disabled = e.page >= t;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
const Oe = { load: !1, report: !1 };
|
|
991
|
+
async function ze(n, e = {}) {
|
|
992
|
+
var N, P, U;
|
|
993
|
+
const t = { ...Oe, ...e.parts, map: !!e.map }, s = e.source ?? new te(e.baseUrl);
|
|
994
|
+
X(n, t);
|
|
995
|
+
const o = j(n), a = ne();
|
|
996
|
+
t.report && pe(o);
|
|
997
|
+
const l = e.basemap ?? ((P = await ((N = s.config) == null ? void 0 : N.call(s).catch(() => null))) == null ? void 0 : P.basemap), i = await ((U = e.map) == null ? void 0 : U.create(o, a, s, l));
|
|
998
|
+
i == null || i.restoreVisibility();
|
|
999
|
+
const r = async (c) => {
|
|
1000
|
+
try {
|
|
1001
|
+
$.render(await s.query(c.table, c.filters, c.page, a.pageSize));
|
|
1002
|
+
} catch (u) {
|
|
1003
|
+
$.showMessage(`Could not load ${c.table}: ${u.message}`);
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
let h;
|
|
1007
|
+
const d = new x(o, e.history ?? Le(), a, (c) => {
|
|
1008
|
+
F(o, a, (u) => d.selectTable(u)), h == null || h.render(), r(c);
|
|
1009
|
+
});
|
|
1010
|
+
h = new we(
|
|
1011
|
+
o,
|
|
1012
|
+
a,
|
|
1013
|
+
s,
|
|
1014
|
+
(c) => d.go({ table: a.view.table, filters: c, page: 1 })
|
|
1015
|
+
);
|
|
1016
|
+
const g = i ? /* @__PURE__ */ (() => {
|
|
1017
|
+
const c = async (u, w, C = !1) => {
|
|
1018
|
+
await i.highlight(await s.geojson(u, [w]), { point: C });
|
|
1019
|
+
};
|
|
1020
|
+
return {
|
|
1021
|
+
onHighlightStop: (u) => void c("stops", u, !0),
|
|
1022
|
+
onHighlightShape: (u) => void c("shapes", u),
|
|
1023
|
+
onHighlightRoute: (u) => {
|
|
1024
|
+
const w = a.routeShapes.get(u);
|
|
1025
|
+
w && c("shapes", w);
|
|
1026
|
+
},
|
|
1027
|
+
onHighlightLocation: (u) => void c("locations", u)
|
|
1028
|
+
};
|
|
1029
|
+
})() : {}, $ = new He(o, a, {
|
|
1030
|
+
onNavigate: (c, u) => d.selectTable(c, u),
|
|
1031
|
+
onPage: (c) => d.go({ ...d.current(), page: c }),
|
|
1032
|
+
...g
|
|
1033
|
+
}), R = o.el("raw-values-input");
|
|
1034
|
+
R.addEventListener("change", () => {
|
|
1035
|
+
a.rawValues = R.checked, $.redraw();
|
|
1036
|
+
});
|
|
1037
|
+
const T = (c, u, w = !1) => {
|
|
1038
|
+
L == null || L.close(), o.el("source-label").textContent = c.source, t.report && c.metrics && ue(o, c.metrics, u), a.tables = c.tables, a.missing = c.missing ?? [], a.mapDataLoaded = !1, a.routeShapes.clear(), F(o, a, (S) => d.selectTable(S)), i == null || i.refresh().catch((S) => console.error("Map layers failed to load:", S));
|
|
1039
|
+
const C = $e(c.tables, w ? d.fromLocation() : null);
|
|
1040
|
+
C && d.reset(C, c.source);
|
|
1041
|
+
}, L = t.load ? new ge(
|
|
1042
|
+
o,
|
|
1043
|
+
s,
|
|
1044
|
+
(c, u) => T(c, u),
|
|
1045
|
+
() => a.tables.length > 0
|
|
1046
|
+
) : void 0;
|
|
1047
|
+
try {
|
|
1048
|
+
T(await s.tables(), void 0, t.load);
|
|
1049
|
+
} catch (c) {
|
|
1050
|
+
L ? L.open() : $.showMessage(`Could not load the feed: ${c.message}`);
|
|
1051
|
+
}
|
|
1052
|
+
return {
|
|
1053
|
+
destroy() {
|
|
1054
|
+
d.destroy(), i == null || i.destroy(), n.replaceChildren(), n.classList.remove("gtfs-viewer");
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
export {
|
|
1059
|
+
te as R,
|
|
1060
|
+
Le as a,
|
|
1061
|
+
Be as b,
|
|
1062
|
+
ze as m,
|
|
1063
|
+
fe as p
|
|
1064
|
+
};
|