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/dist/map.js ADDED
@@ -0,0 +1,334 @@
1
+ var E = Object.defineProperty;
2
+ var A = (i, t, e) => t in i ? E(i, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : i[t] = e;
3
+ var c = (i, t, e) => A(i, typeof t != "symbol" ? t + "" : t, e);
4
+ const f = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', h = {
5
+ openfreemap: {
6
+ kind: "vector",
7
+ url: "https://tiles.openfreemap.org/styles/positron",
8
+ attribution: `<a href="https://openfreemap.org">OpenFreeMap</a> &copy; <a href="https://www.openmaptiles.org/">OpenMapTiles</a> ${f}`
9
+ },
10
+ esri: {
11
+ kind: "raster",
12
+ url: "https://services.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}",
13
+ attribution: 'Tiles &copy; <a href="https://www.esri.com/">Esri</a>'
14
+ },
15
+ osm: {
16
+ kind: "raster",
17
+ url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
18
+ attribution: f
19
+ },
20
+ // Needs ?api_key=... appended, otherwise the tiles carry a watermark.
21
+ carto: {
22
+ kind: "raster",
23
+ url: "https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
24
+ attribution: `${f} &copy; <a href="https://carto.com/attributions">CARTO</a>`
25
+ },
26
+ none: { kind: "none" }
27
+ }, d = "openfreemap";
28
+ function R(i) {
29
+ return i ? i in h ? h[i] : i.includes("{z}") && i.includes("{x}") && i.includes("{y}") ? { kind: "raster", url: i } : i.startsWith("http") ? { kind: "vector", url: i } : (console.warn(`Unknown basemap "${i}"; falling back to ${d}.`), h[d]) : h[d];
30
+ }
31
+ function C(i) {
32
+ let t = 1 / 0, e = 1 / 0, r = -1 / 0, n = -1 / 0;
33
+ const a = (o) => {
34
+ if (Array.isArray(o) && typeof o[0] == "number") {
35
+ const [s, l] = o;
36
+ t = Math.min(t, s), r = Math.max(r, s), e = Math.min(e, l), n = Math.max(n, l);
37
+ } else Array.isArray(o) && o.forEach(a);
38
+ };
39
+ for (const o of i.features)
40
+ o.geometry && a(o.geometry.coordinates);
41
+ return t === 1 / 0 ? null : [t, e, r, n];
42
+ }
43
+ const g = {
44
+ type: "FeatureCollection",
45
+ features: []
46
+ }, T = () => ({ type: "geojson", data: g }), m = ["locations", "routes", "shapes", "stops", "highlight"];
47
+ function D() {
48
+ return Object.fromEntries(m.map((i) => [i, T()]));
49
+ }
50
+ function M() {
51
+ return [
52
+ // Zones first, so they sit under the lines. A zone is an area a rider can
53
+ // be picked up in, not a thing drawn on top of the network.
54
+ {
55
+ id: "locations-fill",
56
+ type: "fill",
57
+ source: "locations",
58
+ paint: { "fill-color": "#7c3aed", "fill-opacity": 0.12 }
59
+ },
60
+ {
61
+ id: "locations-outline",
62
+ type: "line",
63
+ source: "locations",
64
+ paint: { "line-color": "#7c3aed", "line-width": 1, "line-opacity": 0.5 }
65
+ },
66
+ // Shapes no route claims (feeds whose trips.txt has no shape_id) still need
67
+ // drawing, otherwise those feeds render an empty map.
68
+ {
69
+ id: "shapes",
70
+ type: "line",
71
+ source: "shapes",
72
+ paint: { "line-color": "#9ca3af", "line-width": 1.5, "line-opacity": 0.6 }
73
+ },
74
+ {
75
+ id: "routes-white",
76
+ type: "line",
77
+ source: "routes",
78
+ layout: { "line-cap": "round", "line-join": "round" },
79
+ paint: {
80
+ "line-color": "#ffffff",
81
+ "line-width": ["match", ["get", "route_type"], "3", 5, "1", 10, 7],
82
+ "line-opacity": 0.8
83
+ }
84
+ },
85
+ {
86
+ id: "routes",
87
+ type: "line",
88
+ source: "routes",
89
+ layout: { "line-cap": "round", "line-join": "round" },
90
+ paint: {
91
+ "line-color": ["concat", "#", ["get", "route_color"]],
92
+ "line-width": ["match", ["get", "route_type"], "3", 1, "1", 4, 3],
93
+ "line-opacity": ["match", ["get", "route_type"], "1", 0.8, "3", 0.6, 0.7]
94
+ }
95
+ },
96
+ {
97
+ id: "stops",
98
+ type: "circle",
99
+ source: "stops",
100
+ paint: {
101
+ // Scaled by zoom rather than hidden at low zoom: an inspection tool has
102
+ // to show where the stops are before you know where to zoom.
103
+ "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 1.5, 12, 3, 16, 5],
104
+ "circle-color": "#1c1e21",
105
+ "circle-opacity": 0.45
106
+ }
107
+ },
108
+ {
109
+ id: "highlight-fill",
110
+ type: "fill",
111
+ source: "highlight",
112
+ filter: ["==", ["geometry-type"], "Polygon"],
113
+ paint: { "fill-color": "#ff0066", "fill-opacity": 0.25 }
114
+ },
115
+ {
116
+ id: "highlight-line",
117
+ type: "line",
118
+ source: "highlight",
119
+ filter: ["!=", ["geometry-type"], "Point"],
120
+ layout: { "line-cap": "round", "line-join": "round" },
121
+ paint: { "line-color": "#ff0066", "line-width": 5, "line-opacity": 0.95 }
122
+ },
123
+ {
124
+ id: "highlight-point-outer",
125
+ type: "circle",
126
+ source: "highlight",
127
+ filter: ["==", ["geometry-type"], "Point"],
128
+ paint: { "circle-radius": 9, "circle-color": "#ffffff", "circle-opacity": 1 }
129
+ },
130
+ {
131
+ id: "highlight-point",
132
+ type: "circle",
133
+ source: "highlight",
134
+ filter: ["==", ["geometry-type"], "Point"],
135
+ paint: { "circle-radius": 6, "circle-color": "#ff0066", "circle-opacity": 1 }
136
+ }
137
+ ];
138
+ }
139
+ function S() {
140
+ return { version: 8, sources: {}, layers: [] };
141
+ }
142
+ async function P(i) {
143
+ let t = S();
144
+ if (i.kind === "vector" && i.url)
145
+ try {
146
+ const e = await fetch(i.url);
147
+ if (!e.ok) throw new Error(`${e.status} ${e.statusText}`);
148
+ if (t = await e.json(), i.attribution)
149
+ for (const r of Object.values(t.sources))
150
+ r.attribution = i.attribution;
151
+ } catch (e) {
152
+ console.error(`Basemap ${i.url} could not be loaded; continuing without one.`, e), t = S();
153
+ }
154
+ else i.kind === "raster" && i.url && (t.sources.basemap = {
155
+ type: "raster",
156
+ tiles: [i.url],
157
+ tileSize: 256,
158
+ attribution: i.attribution
159
+ }, t.layers.push({ id: "basemap", type: "raster", source: "basemap", minzoom: 0, maxzoom: 22 }));
160
+ return Object.assign(t.sources, D()), t.layers.push(...M()), t;
161
+ }
162
+ const x = 2, O = 5e3;
163
+ function $(i = O, t = x) {
164
+ let e = i;
165
+ return (r) => r < e ? !1 : (e *= t, !0);
166
+ }
167
+ const L = "gtfs-garage.map-visible", I = ["routes", "locations", "stops", "shapes"], y = {
168
+ routes: "routes",
169
+ stops: "stops",
170
+ shapes: "shapes",
171
+ locations: "zones"
172
+ };
173
+ class w {
174
+ constructor(t, e, r, n, a) {
175
+ c(this, "map");
176
+ c(this, "ready");
177
+ /** Cancels an in-flight refresh when a new feed arrives mid-draw. */
178
+ c(this, "drawing", null);
179
+ /** The first full draw, while it is still running. See `highlight`. */
180
+ c(this, "pendingRefresh", null);
181
+ /** Layers the server thinned to fit, and by how much. */
182
+ c(this, "simplifiedLayers", /* @__PURE__ */ new Map());
183
+ this.maplibregl = t, this.dom = e, this.state = r, this.source = n, this.map = new t.Map({
184
+ // The element itself, not an id: the viewer builds its own markup and
185
+ // tags elements with data-el, so there is no id for MapLibre to find.
186
+ container: e.el("map"),
187
+ center: [0, 20],
188
+ zoom: 1,
189
+ attributionControl: { compact: !0 },
190
+ // `buildStyle` composes the style from the basemap and this viewer's own
191
+ // layers and is covered by layers.test.ts; it is typed as a plain record
192
+ // rather than against MapLibre, which until now was an untyped global.
193
+ style: a
194
+ }), this.map.addControl(new this.maplibregl.NavigationControl(), "top-right"), this.map.addControl(new this.maplibregl.ScaleControl()), this.map.on("click", "stops", (o) => {
195
+ const s = o.features[0].properties;
196
+ new this.maplibregl.Popup().setLngLat(o.lngLat).setHTML(`<strong>${s.stop_name ?? ""}</strong><br>${s.stop_id}`).addTo(this.map);
197
+ });
198
+ for (const o of ["stops", "routes"])
199
+ this.map.on("mouseenter", o, () => this.map.getCanvas().style.cursor = "pointer"), this.map.on("mouseleave", o, () => this.map.getCanvas().style.cursor = "");
200
+ this.ready = new Promise((o) => {
201
+ const s = () => {
202
+ this.map.getSource("stops") ? o() : window.setTimeout(s, 50);
203
+ };
204
+ s();
205
+ }), this.dom.el("toggle-map-btn").addEventListener("click", () => this.setVisible(!this.isVisible()));
206
+ }
207
+ /**
208
+ * A vector basemap has to be fetched before the map is constructed, and
209
+ * MapLibre itself is fetched here rather than imported at the top of the
210
+ * file.
211
+ *
212
+ * That is what keeps it optional. A host that mounts the viewer without the
213
+ * map part never reaches this method, so its bundler never follows the
214
+ * import and none of MapLibre's ~1 MB is downloaded. A static import would
215
+ * make it a cost every consumer pays whether or not they show a map.
216
+ */
217
+ static async create(t, e, r, n) {
218
+ const [a, o] = await Promise.all([
219
+ import("maplibre-gl").then((s) => s.default),
220
+ P(R(n))
221
+ ]);
222
+ return new w(a, t, e, r, o);
223
+ }
224
+ isVisible() {
225
+ return !this.dom.el("map-pane").hidden;
226
+ }
227
+ /** Restore the saved preference; defaults to shown. */
228
+ restoreVisibility() {
229
+ let t = null;
230
+ try {
231
+ t = localStorage.getItem(L);
232
+ } catch {
233
+ }
234
+ this.setVisible(t !== "0");
235
+ }
236
+ setVisible(t) {
237
+ this.dom.el("map-pane").hidden = !t, this.dom.el("toggle-map-btn").textContent = t ? "Hide map" : "Show map";
238
+ try {
239
+ localStorage.setItem(L, t ? "1" : "0");
240
+ } catch {
241
+ }
242
+ t && (this.map.resize(), this.state.mapDataLoaded || (this.pendingRefresh = this.refresh().catch((e) => console.error("Map layers failed to load:", e)).finally(() => {
243
+ this.pendingRefresh = null;
244
+ })));
245
+ }
246
+ async setSourceData(t, e) {
247
+ await this.ready, this.map.getSource(t).setData(e);
248
+ }
249
+ fitTo(t, e) {
250
+ const r = C(t);
251
+ r && this.map.fitBounds(r, { padding: 40, maxZoom: e, duration: 600 });
252
+ }
253
+ async highlight(t, e = {}) {
254
+ this.isVisible() || this.setVisible(!0), this.pendingRefresh && await this.pendingRefresh, await this.setSourceData("highlight", t), t.features.length && this.fitTo(t, e.point ? 16 : 14);
255
+ }
256
+ showProgress(t) {
257
+ const e = this.dom.el("map-progress");
258
+ e.textContent = t, e.hidden = t === "";
259
+ }
260
+ /**
261
+ * Draw one layer as it arrives.
262
+ *
263
+ * The features are handed over repeatedly rather than once at the end, so a
264
+ * large feed paints steadily instead of freezing the tab and then jumping.
265
+ * Returns what was collected, for the caller to frame the view with.
266
+ */
267
+ async drawLayer(t, e) {
268
+ const r = { type: "FeatureCollection", features: [] };
269
+ let n = 0, a = !1;
270
+ const o = $();
271
+ let s = 0, l = Promise.resolve();
272
+ await this.source.geojsonStream(
273
+ t,
274
+ (p) => {
275
+ if (p.type === "header") {
276
+ n = p.total, p.step > 1 && (s = p.step), this.showProgress(`Drawing 0 of ${n.toLocaleString()} ${y[t]}…`);
277
+ return;
278
+ }
279
+ const u = p.features;
280
+ r.features.push(...u);
281
+ const _ = Math.min(r.features.length, n);
282
+ if (this.showProgress(
283
+ `Drawing ${_.toLocaleString()} of ${n.toLocaleString()} ${y[t]}…`
284
+ ), t === "routes")
285
+ for (const b of u)
286
+ this.state.routeShapes.set(b.properties.route_id, b.properties.shape_id);
287
+ a || (a = !0, this.fitTo({ type: "FeatureCollection", features: u }, 13)), o(r.features.length) && (l = l.then(() => this.setSourceData(t, r)));
288
+ },
289
+ e
290
+ ), await l, await this.setSourceData(t, r), s && this.simplifiedLayers.set(t, s);
291
+ }
292
+ /** Names the layers the server had to reduce, once everything is drawn. */
293
+ simplificationNote() {
294
+ const t = [...this.simplifiedLayers.keys()].map((e) => y[e]);
295
+ return t.length ? `Showing every point of ${t.join(" and ")} would not fit in a browser, so their lines are simplified. No shape is missing.` : "";
296
+ }
297
+ /** Fetch and draw the whole feed. Skipped entirely while the map is hidden. */
298
+ async refresh() {
299
+ var e;
300
+ if (!this.isVisible()) return;
301
+ (e = this.drawing) == null || e.abort();
302
+ const t = new AbortController();
303
+ this.drawing = t, this.simplifiedLayers.clear(), this.state.routeShapes.clear();
304
+ for (const r of m) await this.setSourceData(r, g);
305
+ try {
306
+ for (const r of I) await this.drawLayer(r, t.signal);
307
+ this.state.mapDataLoaded = !0;
308
+ } catch (r) {
309
+ if (r.name !== "AbortError") throw r;
310
+ } finally {
311
+ this.drawing === t && (this.drawing = null, this.showProgress(this.simplificationNote()));
312
+ }
313
+ }
314
+ async clear() {
315
+ for (const t of m) await this.setSourceData(t, g);
316
+ }
317
+ /**
318
+ * Give up the map and everything holding it.
319
+ *
320
+ * An embedded viewer is unmounted and remounted by its host - React does it
321
+ * twice on every mount in development - and a MapLibre instance left behind
322
+ * keeps a WebGL context, which browsers allow only a handful of.
323
+ */
324
+ destroy() {
325
+ var t;
326
+ (t = this.drawing) == null || t.abort(), this.map.remove();
327
+ }
328
+ }
329
+ const z = {
330
+ create: (i, t, e, r) => w.create(i, t, e, r)
331
+ };
332
+ export {
333
+ z as mapPart
334
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The viewer's own markup.
3
+ *
4
+ * Built here rather than written into a page, so a viewer can be mounted into
5
+ * any root - which is what an embedded one needs. Elements are tagged with
6
+ * `data-el` rather than `id`: two viewers on one page would otherwise put
7
+ * duplicate ids into their host's document, which is invalid and can collide
8
+ * with the host's own selectors. `createDom` looks them up by that attribute,
9
+ * so nothing outside this file cares which it is.
10
+ *
11
+ * `browse` is the part worth embedding - the file list, the grid, the filters.
12
+ * `load` and `report` belong to the local application: a host that already
13
+ * knows which dataset it is showing has nothing to open and no load to explain.
14
+ */
15
+ export interface ViewerParts {
16
+ /** Opening a feed by drop, path or URL. Local application only. */
17
+ load?: boolean;
18
+ /** The load report in the header. Local application only. */
19
+ report?: boolean;
20
+ /** The map pane. */
21
+ map?: boolean;
22
+ }
23
+ /** Replace `root`'s contents with the viewer's markup. */
24
+ export declare function buildViewer(root: Element, parts?: ViewerParts): void;