PageSourceSearch

https://bikesontransit.org/js/map-view/clip-and-export.js

js bikesontransit.org collected 2026-09-27 01:51:15 UTC 5,635 bytes, 162 lines download raw bytes

1/* js/map-view/clip-and-export.js
2   Client-side clipping of polygon features against the basemap's
3   water layer, exported as GeoJSON for use in QGIS. Driven by the
4   bulk-selection toolbar.
5
6   Approach: query the visible Mapbox vector tiles for the 'water'
7   source-layer (works for Mapbox Streets / Light / Outdoors / Dark),
8   union those polygons into a single water mask, then run
9   turf.difference on each selected polygon to produce the land-only
10   geometry. Download as a single GeoJSON FeatureCollection.
11
12   Caveats:
13   - Water polygons come from whatever Mapbox has loaded for the
14     current view. If you zoom in past the project bbox, water
15     outside the view is not in the source and will not clip. Zoom
16     out to cover the full project before exporting.
17   - Vector tiles are clipped at tile boundaries, so the helper
18     unions all water features before differencing.
19   - For basemaps with no 'water' source layer (some custom or
20     satellite styles), the export throws a clear error. */
21
22import * as turf from '@turf/turf';
23
24function getWaterFeatures(map) {
25  // Query every vector source on the style for a 'water' sub-layer.
26  // The base 'composite' source covers Mapbox Streets / Light /
27  // Outdoors / Dark; iterating handles custom styles that mix in
28  // additional vector sources.
29  const styleSources = map.getStyle()?.sources || {};
30  const out = [];
31  for (const srcId of Object.keys(styleSources)) {
32    const src = styleSources[srcId];
33    if (!src || src.type !== 'vector') continue;
34    try {
35      const feats = map.querySourceFeatures(srcId, { sourceLayer: 'water' });
36      if (feats?.length) out.push(...feats);
37    } catch { /* not a queryable vector source; skip */ }
38  }
39  return out;
40}
41
42function unionAll(features) {
43  if (!features.length) return null;
44  let agg = turf.feature(features[0].geometry);
45  for (let i = 1; i < features.length; i++) {
46    const f = features[i];
47    if (!f.geometry) continue;
48    try {
49      agg = turf.union(agg, turf.feature(f.geometry)) || agg;
50    } catch (err) {
51      // Tile boundaries occasionally produce self-touching geometry
52      // that turf.union rejects. Skipping a fragment is safer than
53      // failing the whole export; the missing chunk just stays in
54      // the clip output, which the user can re-export after zooming.
55      console.warn('[clip-export] union skipped a water fragment', err);
56    }
57  }
58  return agg;
59}
60
61function downloadGeoJSON(featureCollection, filename) {
62  const blob = new Blob(
63    [JSON.stringify(featureCollection, null, 2)],
64    { type: 'application/geo+json' }
65  );
66  const url = URL.createObjectURL(blob);
67  const a = document.createElement('a');
68  a.href = url;
69  a.download = filename;
70  document.body.appendChild(a);
71  a.click();
72  document.body.removeChild(a);
73  setTimeout(() => URL.revokeObjectURL(url), 1000);
74}
75
76/**
77 * Clip the polygons in `featureIds` against the basemap water and
78 * trigger a GeoJSON download. Returns { exported, total, waterTiles }.
79 *
80 * Skipped: features without polygon/multipolygon geometry, features
81 * not found in window.wmState.features, and features whose
82 * turf.difference call throws (rare topology errors).
83 */
84export async function exportClippedToLand({ featureIds, filename }) {
85  const map = window.wmState?.map;
86  if (!map) throw new Error('Map is not initialized.');
87  if (!featureIds?.length) throw new Error('No polygons to export.');
88
89  const waterFeatures = getWaterFeatures(map);
90  if (!waterFeatures.length) {
91    throw new Error(
92      'No water polygons available from the basemap at this zoom. ' +
93      'Zoom out so the full project area is visible, or switch to ' +
94      'a Mapbox Streets / Light / Outdoors basemap before exporting.'
95    );
96  }
97
98  const waterUnion = unionAll(waterFeatures);
99
100  const idSet = new Set(featureIds.map(Number));
101  const allFeatures = window.wmState?.features || [];
102  const candidates = allFeatures.filter(f => idSet.has(Number(f.id)));
103
104  const out = [];
105  let skippedNonPolygon = 0;
106  let skippedClipError = 0;
107
108  for (const f of candidates) {
109    const geom = f.geometry;
110    if (!geom || (geom.type !== 'Polygon' && geom.type !== 'MultiPolygon')) {
111      skippedNonPolygon++;
112      continue;
113    }
114
115    // Surface the source feature's data on the export so QGIS gets
116    // GEOID, NAME, ALAND, AWATER plus any latestAnswers (the ACS
117    // values populated by the Census tab).
118    const properties = {
119      feature_id: f.id,
120      ...(f.properties || {}),
121      ...(f.latestAnswers || {})
122    };
123
124    const sourceFeature = turf.feature(geom, properties);
125
126    let clipped;
127    try {
128      clipped = waterUnion ? turf.difference(sourceFeature, waterUnion) : sourceFeature;
129    } catch (err) {
130      console.warn('[clip-export] difference failed for feature', f.id, err);
131      skippedClipError++;
132      continue;
133    }
134
135    if (clipped) {
136      out.push(clipped);
137    } else {
138      // turf.difference returns null when the source is entirely
139      // covered by the clip mask. That would be a tract entirely in
140      // water, which is unusual but worth keeping out of the file.
141      skippedClipError++;
142    }
143  }
144
145  if (!out.length) {
146    throw new Error(
147      'No land-only polygons to export. Check that selected features ' +
148      'are polygons and that the basemap has water coverage for the area.'
149    );
150  }
151
152  const fc = turf.featureCollection(out);
153  downloadGeoJSON(fc, filename || `clipped-to-land-${Date.now()}.geojson`);
154
155  return {
156    exported: out.length,
157    requested: featureIds.length,
158    waterFragments: waterFeatures.length,
159    skippedNonPolygon,
160    skippedClipError
161  };
162}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.