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.