modules/services/esri_data.js (249 lines of code) (raw):

import { dispatch as d3_dispatch } from 'd3-dispatch'; import { json as d3_json } from 'd3-fetch'; import { select as d3_select } from 'd3-selection'; import { Projection, Tiler } from '@id-sdk/math'; import { coreGraph, coreTree } from '../core'; import { osmNode, osmRelation, osmWay } from '../osm'; import { utilRebind } from '../util'; const GROUPID = 'bdf6c800b3ae453b9db239e03d7c1727'; const APIROOT = 'https://openstreetmap.maps.arcgis.com/sharing/rest/content'; const HOMEROOT = 'https://openstreetmap.maps.arcgis.com/home'; const TILEZOOM = 14; const tiler = new Tiler().zoomRange(TILEZOOM); const dispatch = d3_dispatch('loadedData'); let _datasets = {}; let _gotDatasets = false; let _off; function abortRequest(controller) { controller.abort(); } // API //https://developers.arcgis.com/rest/users-groups-and-items/search.htm function searchURL(start) { return `${APIROOT}/groups/${GROUPID}/search?num=100&start=${start}&sortField=title&sortOrder=asc&f=json`; // use to get // .results[] // .extent // .id // .thumbnail // .title // .snippet // .url (featureServer) } function layerURL(featureServerURL) { return `${featureServerURL}/layers?f=json`; // should return single layer(?) // .layers[0] // .copyrightText // .fields // .geometryType "esriGeometryPoint" or "esriGeometryPolygon" ? } function itemURL(itemID) { return `${HOMEROOT}/item.html?id=${itemID}`; } function tileURL(dataset, extent) { const layerId = dataset.layer.id; const bbox = extent.toParam(); return `${dataset.url}/${layerId}/query?f=geojson&outfields=*&outSR=4326&geometryType=esriGeometryEnvelope&geometry=${bbox}`; } // Add each dataset to _datasets, create internal state function parseDataset(ds) { if (_datasets[ds.id]) return; // unless we've seen it already _datasets[ds.id] = ds; ds.graph = coreGraph(); ds.tree = coreTree(ds.graph); ds.cache = { inflight: {}, loaded: {}, seen: {}, origIdTile: {} }; // cleanup the `licenseInfo` field by removing styles (not used currently) let license = d3_select(document.createElement('div')); license.html(ds.licenseInfo); // set innerHtml license.selectAll('*') .attr('style', null) .attr('size', null); ds.license_html = license.html(); // get innerHtml // generate public link to this item ds.itemURL = itemURL(ds.id); } function parseTile(dataset, tile, geojson, callback) { if (!geojson) return callback({ message: 'No GeoJSON', status: -1 }); // expect a FeatureCollection with `features` array let results = []; (geojson.features || []).forEach(f => { let entities = parseFeature(f, dataset); if (entities) results.push.apply(results, entities); }); callback(null, results); } function parseFeature(feature, dataset) { const geom = feature.geometry; const props = feature.properties; if (!geom || !props) return null; const featureID = props[dataset.layer.idfield] || props.OBJECTID || props.FID || props.id; if (!featureID) return null; // skip if we've seen this feature already on another tile if (dataset.cache.seen[featureID]) return null; dataset.cache.seen[featureID] = true; const id = `${dataset.id}-${featureID}`; const meta = { __fbid__: id, __origid__: id, __service__: 'esri', __datasetid__: dataset.id }; let entities = []; let nodemap = new Map(); // Point: make a single node if (geom.type === 'Point') { return [ new osmNode({ loc: geom.coordinates, tags: parseTags(props) }, meta) ]; // LineString: make nodes, single way } else if (geom.type === 'LineString') { const nodelist = parseCoordinates(geom.coordinates); if (nodelist.length < 2) return null; const w = new osmWay({ nodes: nodelist, tags: parseTags(props) }, meta); entities.push(w); return entities; // Polygon: make nodes, way(s), possibly a relation } else if (geom.type === 'Polygon') { let ways = []; geom.coordinates.forEach(ring => { const nodelist = parseCoordinates(ring); if (nodelist.length < 3) return null; const first = nodelist[0]; const last = nodelist[nodelist.length - 1]; if (first !== last) nodelist.push(first); // sanity check, ensure rings are closed const w = new osmWay({ nodes: nodelist }); ways.push(w); }); if (ways.length === 1) { // single ring, assign tags and return entities.push( ways[0].update( Object.assign({ tags: parseTags(props) }, meta) ) ); } else { // multiple rings, make a multipolygon relation with inner/outer members const members = ways.map((w, i) => { entities.push(w); return { id: w.id, role: (i === 0 ? 'outer' : 'inner'), type: 'way' }; }); const tags = Object.assign(parseTags(props), { type: 'multipolygon' }); const r = new osmRelation({ members: members, tags: tags }, meta); entities.push(r); } return entities; } // no Multitypes for now (maybe not needed) function parseCoordinates(coords) { let nodelist = []; coords.forEach(coord => { const key = coord.toString(); let n = nodemap.get(key); if (!n) { n = new osmNode({ loc: coord }); entities.push(n); nodemap.set(key, n); } nodelist.push(n.id); }); return nodelist; } function parseTags(props) { let tags = {}; Object.keys(props).forEach(prop => { const k = clean(dataset.layer.tagmap[prop]); const v = clean(props[prop]); if (k && v) { tags[k] = v; } }); tags.source = `esri/${dataset.name}`; return tags; } function clean(val) { return val ? val.toString().trim() : null; } } export default { init: function () { this.event = utilRebind(this, dispatch, 'on'); }, reset: function () { Object.values(_datasets).forEach(ds => { if (ds.cache.inflight) { Object.values(ds.cache.inflight).forEach(abortRequest); } ds.graph = coreGraph(); ds.tree = coreTree(ds.graph); ds.cache = { inflight: {}, loaded: {}, seen: {}, origIdTile: {} }; }); return this; }, graph: function (datasetID) { const ds = _datasets[datasetID]; return ds && ds.graph; }, intersects: function (datasetID, extent) { const ds = _datasets[datasetID]; if (!ds || !ds.tree || !ds.graph) return []; return ds.tree.intersects(extent, ds.graph); }, toggle: function (val) { _off = !val; return this; }, loadTiles: function (datasetID, projection) { if (_off) return; // `loadDatasets` and `loadLayer` are asynchronous, // so ensure both have completed before we start requesting tiles. const ds = _datasets[datasetID]; if (!ds || !ds.layer) return; const cache = ds.cache; const tree = ds.tree; const graph = ds.graph; const proj = new Projection().transform(projection.transform()).dimensions(projection.clipExtent()); const tiles = tiler.getTiles(proj).tiles; // abort inflight requests that are no longer needed Object.keys(cache.inflight).forEach(k => { const wanted = tiles.find(tile => tile.id === k); if (!wanted) { abortRequest(cache.inflight[k]); delete cache.inflight[k]; } }); tiles.forEach(tile => { if (cache.loaded[tile.id] || cache.inflight[tile.id]) return; const controller = new AbortController(); const url = tileURL(ds, tile.wgs84Extent); d3_json(url, { signal: controller.signal }) .then(geojson => { delete cache.inflight[tile.id]; if (!geojson) throw new Error('no geojson'); parseTile(ds, tile, geojson, (err, results) => { if (err) throw new Error(err); graph.rebase(results, [graph], true); tree.rebase(results, true); cache.loaded[tile.id] = true; dispatch.call('loadedData'); }); }) .catch(() => { /* ignore */ }); cache.inflight[tile.id] = controller; }); }, loadDatasets: function () { if (_gotDatasets) { return Promise.resolve(_datasets); } else { return new Promise((resolve, reject) => { let start = 1; fetchMore(start); function fetchMore(start) { d3_json(searchURL(start)) .then(json => { (json.results || []).forEach(ds => parseDataset(ds)); if (json.nextStart > 0) { fetchMore(json.nextStart); // fetch next page } else { _gotDatasets = true; // no more pages resolve(_datasets); } }) .catch(err => { _gotDatasets = false; reject(err); }); } }); } }, loadLayer: function (datasetID) { let ds = _datasets[datasetID]; if (!ds || !ds.url) { return Promise.reject(`Unknown datasetID: ${datasetID}`); } else if (ds.layer) { return Promise.resolve(ds.layer); } return d3_json(layerURL(ds.url)) .then(json => { if (!json.layers || !json.layers.length) { throw new Error(`Missing layer info for datasetID: ${datasetID}`); } ds.layer = json.layers[0]; // should return a single layer // Use the field metadata to map to OSM tags let tagmap = {}; ds.layer.fields.forEach(f => { if (f.type === 'esriFieldTypeOID') { // this is an id field, remember it ds.layer.idfield = f.name; } if (!f.editable) return; // 1. keep "editable" fields only tagmap[f.name] = f.alias; // 2. field `name` -> OSM tag (stored in `alias`) }); ds.layer.tagmap = tagmap; return ds.layer; }) .catch(() => { /* ignore */ }); } };