1/* TODO - 2 3 - Add noscript message 4 - Populate addressRangeList (not sure if this is worth doing without the full address range data from Geosupport.) 5 - Expand "Blvd" (maybe also "Blvd."?) before search ("213-03 NORTHERN blvd" gives wrong result) 6 - If no housenumber match is found in geosearch, look for address matches in DOB NOW data 7 - Zoom to BBL (or just BB?) if no latlon found 8 - Links to NY state offering plan search 9 - 855 Clarkson Brooklyn 10 11 "1745 Forest Avenue, Staten Island, New York 10302" vs "1745 Forest Avenue"?????? 12 13 BIN 3428826, 3337899, 3425581 -- examples where DOB has correct building height, height listed with footprint is much higher 14 15 https://a836-acris.nyc.gov/DS/DocumentSearch/DocumentDetail?doc_id=2014082101390003 ownership search 16*/ 17 18document.getElementById('searchInputId').addEventListener('keyup', checkSearchKey); 19window.addEventListener('popstate', (event) => { doParamSearch(event.state.search ?? ''); }); 20const searchInput = document.getElementById('searchInputId'); 21const addressDiv = document.getElementById('addressDivId'); 22const binDiv = document.getElementById('binDivId'); 23const bblDiv = document.getElementById('bblDivId'); 24const hpdDiv = document.getElementById('hpdDivId'); 25const buildingClassDiv = document.getElementById('buildingClassDivId'); 26const addressRangeList = document.getElementById('addressRangeListId'); 27const infoTable = document.getElementById('infoTableId'); 28const bblRegex = /^([1-5])([0-9]{5})([0-9]{4})$/; 29const latlonRegex = /^\s*([0-9\.]+)[\s,;]+(\-[0-9\.]+)\s*$/; 30const urlDobNow = 'https://a810-dobnow.nyc.gov/publish/#!/'; 31const boros = ['Manhattan', 'Bronx', 'Brooklyn', 'Queens', 'Staten Island']; 32var slippyMap = null; 33var markerLatLon = null; 34var footprintJson = []; 35var footprintDrawn = false; 36var bin = ''; 37 38const params = (new URL(document.location)).searchParams; 39const paramSearch = params.get('search'); 40if (paramSearch === null) { 41 clearSearchLog(); 42 slippyMapDefault(); 43} else { 44 doParamSearch(paramSearch.trim()); 45} 46 47 48/* SEARCH FUNCTIONS */ 49 50function checkSearchKey(e) { 51 if (e.keyCode === 13) { 52 doSearch(); 53 } 54} 55 56function doParamSearch(p) { 57 searchInput.value = p; 58 doSearch(false); 59} 60 61async function doSearch(addToHistory = true) { 62 const searchText = searchInput.value.trim(); 63 searchInput.value = searchText; 64 clearIoElements(); 65 clearSearchLog(); 66 markerLatLon = null; 67 footprintJson = []; 68 footprintDrawn = false; 69 bin = ''; 70 if (addToHistory) { 71 history.pushState({search: searchText}, '', window.location.href.split(/[?#]/)[0] + '?search=' + encodeURIComponent(searchText)); 72 } 73 74 //document.getElementById('searchLogTextareaId').style.color = 'black'; 75 const reMatch = searchText.match(latlonRegex); 76 if (reMatch !== null) { 77 writeSearchLog('Search text "' + searchText + '" ' + "looks like a latlon pair\r\nAttempting latlon search...\r\n"); 78 await doFootprintLatlonSearch(reMatch[1], reMatch[2]); 79 } else if (validBin(searchText)) { 80 writeSearchLog('Search text "' + searchText + '" looks like a BIN\r\nAttempting BIN search...\r\n'); 81 writeBin(searchText); 82 await doBinSearch(searchText); 83 } else { 84 writeSearchLog('Search text "' + searchText + '" ' + "doesn't look like a BIN or latlon\r\nAttempting address search...\r\n"); 85 await doAddressSearch(searchText); 86 } 87 88 if (addressDiv.innerHTML === '') { 89 writeFailedAddress(); 90 } 91 92 if (!footprintDrawn) { 93 /* No footprint was drawn... If we got latlon from one of the API calls, add a marker and zoom 94 there, otherwise reset map to default state. */ 95 if (markerLatLon === null) { 96 slippyMapDefault(); 97 } else { 98 slippyMapAddMarker(markerLatLon); 99 } 100 } 101} 102 103async function doFootprintLatlonSearch(lat, lon) { 104 markerLatLon = [lat, lon]; 105 const latLonApiQuery = 'https://data.cityofnewyork.us/api/geospatial/5zhs-2jue?lat=' + lat + '&lng=' + lon + '&zoom=17'; 106 let fpJson = await httpGetJson(latLonApiQuery, '"Building Footprints" latlon'); 107 if (fpJson.length > 0) { 108 const latlonBin = fpJson[0].bin; 109 if (validBin(latlonBin)) { 110 writeSearchLog(' - found BIN ' + latlonBin + ', attemping BIN search...\r\n'); 111 writeBin(latlonBin); 112 await doBinSearch(latlonBin); 113 } 114 } 115} 116 117async function doBinSearch(searchBin) { 118 bin = searchBin; 119 doHpdSearch(); 120 await doFootprintBinSearch(); 121 await doDobJobSearch(); 122 await doDobNowSearch(); 123} 124 125async function doFootprintBinSearch() { 126 127 function footprintFeatureText(featureCode) { 128 //Codes taken from https://github.com/CityOfNewYork/nyc-geo-metadata/blob/master/Metadata/Metadata_BuildingFootprints.md and https://github.com/CityOfNewYork/nyc-planimetrics/blob/master/Capture_Rules.md#building-footprint 129 const featureCodes = { 130 2100: 'Building', 131 5100: 'Construction', 132 2110: 'Garage', 133 1001: 'Fuel Canopy', 134 1002: 'Tank', 135 1003: 'Placeholder', 136 1004: 'Auxiliary', 137 1005: 'Temporary', 138 5110: 'Garage' 139 }; 140 return featureCodes[featureCode] ?? featureCode; 141 } 142 143 let row = infoTable.insertRow(-1); 144 row.className = 'rowHead'; 145 row.innerHTML = '<td>Footprint</td><td>Yr Built</td><td>Status</td><td>Date</td><td>Height</td>'; 146 147 const buildingFootprintApiQuery = 'https://data.cityofnewyork.us/resource/5zhs-2jue.json?bin=' + bin; 148 footprintJson = await httpGetJson(buildingFootprintApiQuery, '"Building Footprint" BIN'); 149 if ((footprintJson[0]?.the_geom?.type ?? '') !== 'MultiPolygon') { 150 /* NYC's building footprints API endpoint (https://data.cityofnewyork.us/resource/5zhs-2jue.json) 151 has been known to temporarily switch places with the building center points API endpoint 152 (https://data.cityofnewyork.us/resource/u9wf-3gbt.json). If this happens, a footprint search by 153 BIN will only return point geometry instead of a multipolygon. (All footprints are recorded as 154 "MultiPolygon" in the city data, even if they're just simple polygons.) 155
156 If the returned geometry contains no multipolygons, we'll try the search again using the building 157 center points API instead. 158 */ 159 const buildingPointApiQuery = 'https://data.cityofnewyork.us/resource/u9wf-3gbt.json?bin=' + bin; 160 footprintJson = await httpGetJson(buildingPointApiQuery, 'Did not get a footprint, trying "Building Point" BIN'); 161 } 162 if (footprintJson.length > 0) { 163 let needBbl = (bblDiv.innerHTML === ''); 164 let heightInMeters = ''; 165 let formattedHeight = ''; 166 let footprintLinks = ''; 167 if (footprintJson.length === 1) { 168 writeSearchLog(' - only one footprint result for this BIN\r\n'); 169 } else { 170 writeSearchLog(' - ' + footprintJson.length + ' footprint results for this BIN\r\n'); 171 } 172 /* Loop through footprint results, adding footprints to slippy map and crafting download 173 links. Theoretically we might want to reverse sort by status date, but I've never 174 actually seen more than one footprint result for a valid BIN. As of now, if the API did 175 return multiple footprints, they would all be listed in the table but only the first 176 result would be added to the map. 177 */ 178 for (let i = 0; i < footprintJson.length; i++) { 179 //Take the BBL from this footprint record, if it's valid and needed 180 if (needBbl) { 181 const bbl = footprintJson[i].base_bbl.trim(); 182 const reMatch = bbl.match(bblRegex); 183 if (reMatch === null) { 184 writeSearchLog(' - not using invalid BBL ' + bbl + '\r\n'); 185 } else { 186 writeBbl(reMatch[1], reMatch[2], reMatch[3]); 187 writeSearchLog(' - showing BBL ' + bbl + '\r\n'); 188 needBbl = false; 189 } 190 } 191 192 if (!footprintDrawn) { 193 slippyMapAddFootprint(footprintJson[i].the_geom); 194 footprintDrawn = true; 195 } 196 197 row = infoTable.insertRow(-1); 198 row.className = 'rowBg' + (i % 2); 199 if (typeof footprintJson[i].height_roof === 'undefined') { 200 heightInMeters = ''; 201 formattedHeight = '?'; 202 } else { 203 heightInMeters = feetToMeters(footprintJson[i].height_roof); 204 formattedHeight = formatHeight(footprintJson[i].height_roof, heightInMeters); 205 } 206 207 footprintLinks = ''; 208 if (footprintJson[i].the_geom.type === 'MultiPolygon') { 209 processFootprint(i, bin, heightInMeters); 210 if (typeof(footprintJson[i]?.nycaabs_osm_xml) !== 'undefined') { 211 footprintLinks = '<a href="data:text/xml;charset=utf-8,' + encodeURIComponent(footprintJson[i].nycaabs_osm_xml) + '" download="bin' + bin + '_footprint.osm">Download as .osm</a> <a class="josmLink" href="javascript:void(0);" onclick="javascript:sendFootprintToJosm(' + i + ')">Send to JOSM</a>'; 212 } 213 //Possibly consider a geojson download link as well -- iD users would be able to 214 //load this, though it only works as an imagery layer, not importable data. 215 } 216 217 row.innerHTML = '<td>' + footprintFeatureText(footprintJson[i].feature_code) + '</td><td>' + (footprintJson[i].construction_year ?? '?') + '</td><td>' + footprintJson[i].last_status_type + '</td><td>' + footprintJson[i].last_edited_date.slice(0,10) + '</td><td>' + formattedHeight + '</td><td class="tdLink">' + footprintLinks + '</td>'; 218 } 219 } else { 220 writeSearchLog(' - no footprint results for this BIN\r\n'); 221 row = infoTable.insertRow(-1); 222 row.innerHTML = '<td>none found</td>'; 223 } 224} 225 226async function doHpdSearch() { 227 const dobHpdApiQuery = 'https://data.cityofnewyork.us/resource/kj4p-ruqc.json?bin=' + bin; 228 let json = await httpGetJson(dobHpdApiQuery, '"Buildings Subject to HPD Jurisdiction"'); 229 let j = json?.length ?? 0; 230 if (j > 0) { 231 if (j === 1) { 232 writeSearchLog(' - only one HPD result for this BIN\r\n'); 233 } else { 234 writeSearchLog(' - ' + j + ' HPD results for this BIN, using result
2340\r\n'); 235 } 236 let needAddress = (addressDiv.innerHTML === ''); 237 let houseNumber = ''; 238 let street = ''; 239 let boroCode = bin.slice(0,1); 240 let needBbl = (bblDiv.innerHTML === ''); 241 let lot = ''; 242 let block = ''; 243 244 if (needAddress) { 245 houseNumber = json[0].housenumber ?? ''; 246 street = json[0].streetname ?? ''; 247 if (houseNumber !== '' && street !== '') { 248 writeSearchLog(' - showing address ' + houseNumber + ' ' + street + ', boro ' + boroCode + '\r\n'); 249 writeAddress(houseNumber, street, boroCode); 250 needAddress = false; 251 } 252 } 253 254 if (needBbl) { 255 block = json[0].block ?? ''; 256 lot = json[0].lot ?? ''; 257 if (block !== '' && lot !== '') { 258 block = block.padStart(5, '0'); 259 lot = lot.padStart(5, '0'); 260 writeBbl(boroCode, block, lot); 261 writeSearchLog(' - showing BBL ' + boroCode + block + lot + '\r\n'); 262 needBbl = false; 263 } 264 } 265 266 writeHpd(json[0].buildingid); 267 } else { 268 writeSearchLog(' - no HPD results for this BIN\r\n'); 269 writeHpd(''); 270 } 271} 272 273async function doDobJobSearch() { 274 275 function dobJobSortRank(type, jobStatus, date, height) { 276 /* Ideally I want the top sort entry to be the same job you'd get if you searched this BIN 277 in BIS -- because that's the job that usually links to the current zoning documents. Users 278 can then simply use the top-ranked "Zoning Docs @ BIS" link to look for the zoning docs, 279 using only one request to the BIS server. (Note that there's no guarantee that BIS will have 280 any zoning docs on offer, even if we pick the "right" job number.) 281 My working theory is that BIS will return the newest non-rejected (status !== 'J') type NB 282 (new building) job, and if no NB job is on record, then the newest A1, then A2, then A3 283 (various levels of building alterations) and after that who knows. So that's approximately 284 what this sort rank does. 285 I have my doubts that this approach will work every time, but I don't know of any 286 counterexamples at the moment. (Please report if found!) 287 288 The full list of DOB job types (taken from BIS's JobsQueryByLocationServlet page) is: 289 A1 - ALTERATION TYPE 1 290 A2 - ALTERATION TYPE 2 291 A3 - ALTERATION TYPE 3 292 DM - FULL DEMOLITION 293 NB - NEW BUILDING 294 PA - PLACE OF ASSEMBLY 295 PR - LAA (ARA) 296 SC - SUBDIVISION - CONDO 297 SG - SIGN 298 SI - SUBDIVISION - IMPROVED 299 SU - SUBDIVISION - UNIMPROVED 300 */ 301 const jobTypes = ['A3', 'A2', 'A1', 'NB']; 302 return parseInt((String(jobTypes.indexOf(type) + ((jobStatus == 'J') ? 1 : 6)) + date.slice(0,4) + date.slice(5,7) + date.slice(8,10)) * 1000000000) + Math.round(height * 1000); 303 } 304 305 function expandJobType(code) { 306 const jobTypes = { A1: 'Alteration Type 1 (Major alteration, changes to building use or occupancy)', 307 A2: 'Alteration Type 2 (Major alteration, no changes to building use or occupancy)', 308 A3: 'Alteration Type 3 (Minor alteration)', 309 DM: 'Full Demolition', 310 NB: 'New Building', 311 PA: 'Place of Assembly', 312 PR: 'Limited Alteration Application (Alteration Repair Application)', 313 SC: 'Subdivision - Condo', 314 SG: 'Sign', 315 SI: 'Subdivision - Improved', 316 SU: 'Subdivision - Unimproved' 317 }; 318 return jobTypes[code] ?? 'Unknown job type code ' + code; 319 } 320 321 function expandJobStatus(code) { 322 const jobStatuses = { A: 'Pre-Filed', 323 B: 'Application Processed - Part-No Payment', 324 C: 'Application Processed - Payment Only', 325 D: 'Application Processed - Completed', 326 E: 'Application Processed - No Plan Exam', 327 F: 'Application Assigned To Plan Examiner', 328 G: 'PAA Fee Due', 329 H: 'Plan Exam - In Process', 330 I: 'Sign-Off (ARA)', 331 J: 'Plan Exam - Disapproved', 332 K: 'Plan Exam - Partial Approval', 333 L: 'Plan Exam PAA - Pending Fee Estimation', 334 M: 'Plan Exam PAA - Fee Resolved', 335 P: 'Plan Exam - Approved', 336 Q: 'Permit Issued - Partial Job', 337 R: 'Permit Issued - Entire Job/Work', 338 U: 'Completed', 339 X: 'Signed-Off', 340 3: 'Suspended' 341 }; 342 return jobStatuses[code] ?? 'Unknown job status code ' + code; 343 } 344 345 function formatDate(input) { 346 /* Dates in the city's DOB job dataset are almost all mm/dd/yyyy but there are a few yyyy-mm-dd thrown in 347 there just to make things fun, BIN 4097171 eg */ 348 input = input.trim(); 349 let matches = input.match(/^(\d\d)\D(\d\d)\D(\d\d\d\d)/); 350 if (matches !== null) { 351 return matches[3] + '-' + matches[1] + '-' + matches[2]; 352 } 353 return input; 354 } 355 356 const buildingClassMap = new Map(); 357 const maxResults = 15; 358 let row = infoTable.insertRow(-1); 359 row.className = 'rowHead'; 360 row.innerHTML = '<td>DOB BIS Job</td><td>Type</td><td>Status</td><td>Date</td><td>Height</td><td class="tdLink"><a href="' + constructUrlBisJobs(bin) + '">Job List @ BIS</a> <a href="' + constructUrlBisJobs(bin, 'A') + '">Active Zoning Job @ BIS</a></td>'; 361 let dobJobApiQuery = 'https://data.cityofnewyork.us/resource/ic3t-wcy2.json?$select=distinct%20job__,house__,street_name,borough,block,lot,building_class,job_type,job_status,latest_action_date,proposed_height,gis_latitude,gis_longitude&$where=bin__=%27' + bin + '%27'; 362 //Note: previously I was also requesting the "job_s1_no" field, and using it to populate the "allisn" url parameter of the BIS Zoning Documents page urls, imitating the urls generated by the BIS web portal. I've concluded that this field isn't neccessary (the Zoning Documents page seems to load just as well without it) so I'm no longer requesting or using it. 363 364 let json = await httpGetJson(dobJobApiQuery, '"DOB Job Application Filings"'); 365 let j = json?.length ?? 0; 366 if (j > 0) { 367 let needAddress = (addressDiv.innerHTML === ''); 368 let houseNumber = ''; 369 let street = '';
370 let boroCode = bin.slice(0,1); 371 let needBbl = (bblDiv.innerHTML === ''); 372 let jobLot = ''; 373 let jobBlock = ''; 374 let actionDate = ''; 375 let buildingClass = ''; 376 if (j === 1) { 377 writeSearchLog(' - only one DOB Job Application result for this BIN\r\n'); 378 } else if (j > maxResults) { 379 writeSearchLog(' - ' + j + ' DOB Job Application results for this BIN, only showing the top ' + maxResults + '\r\n'); 380 j = maxResults; 381 } else { 382 writeSearchLog(' - ' + j + ' DOB Job Application results for this BIN\r\n'); 383 } 384 json.sort(function(a, b) { return dobJobSortRank(b.job_type, b.job_status, formatDate(b.latest_action_date), b.proposed_height) - dobJobSortRank(a.job_type, a.job_status, formatDate(a.latest_action_date), a.proposed_height); }); 385 const listedJobsWithHeight = []; 386 let addedRowCount = 0; 387 for (let i = 0; i < j; i++) { 388 389 if (needAddress) { 390 houseNumber = json[i].house__ ?? ''; 391 street = json[i].street_name ?? ''; 392 if (houseNumber !== '' && street !== '') { 393 writeSearchLog(' - showing address ' + houseNumber + ' ' + street + ', boro ' + boroCode + '\r\n'); 394 writeAddress(houseNumber, street, boroCode); 395 needAddress = false; 396 } 397 } 398 399 if (needBbl) { 400 jobBlock = json[i].block.trim(); 401 jobLot = json[i].lot.trim(); 402 if (jobBlock !== '' && jobLot !== '') { 403 writeBbl(boroCode, json[i].block, json[i].lot); 404 writeSearchLog(' - showing BBL ' + boroCode + json[i].block + json[i].lot + ' from result ' + i + '\r\n'); 405 needBbl = false; 406 } 407 } 408 409 if (markerLatLon === null) { 410 if ((json[i].gis_latitude > 40) && (json[i].gis_longitude < -73)) { 411 markerLatLon = [json[i].gis_latitude, json[i].gis_longitude]; 412 writeSearchLog(' - got latlon ' + json[i].gis_latitude + ', ' + json[i].gis_longitude + ' from result ' + i + '\r\n'); 413 } 414 } 415 416 actionDate = formatDate(json[i].latest_action_date); 417 buildingClass = json[i].building_class.trim(); 418 if (!buildingClassMap.has(buildingClass) || (actionDate > buildingClassMap.get(buildingClass))) { 419 buildingClassMap.set(buildingClass, actionDate); 420 } 421 422 if (!listedJobsWithHeight.includes(json[i].job__)) { 423 row = infoTable.insertRow(-1); 424 row.className = 'rowBg' + (addedRowCount % 2); 425 row.innerHTML = '<td>' + json[i].job__ + '</td><td><abbr title="' + expandJobType(json[i].job_type) + '">' + json[i].job_type + '</abbr></td><td><abbr title="' + expandJobStatus(json[i].job_status) + '">' + json[i].job_status + '</abbr></td><td>' + formatDate(json[i].latest_action_date) + '</td><td>' + formatHeight(json[i].proposed_height) + '</td><td class="tdLink"><a href="https://a810-bisweb.nyc.gov/bisweb/JobsQueryByNumberServlet?passjobnumber=' + json[i].job__ + '&passdocnumber=01">Job Details @ BIS</a> <a href="https://a810-bisweb.nyc.gov/bisweb/JobsZoningDocumentsServlet?passjobnumber=' + json[i].job__ + '&passdocnumber=01&allbin=' + bin + '">Zoning Docs @ BIS</a></td>'; 426 addedRowCount++; 427 if (json[i].proposed_height > 0) { 428 listedJobsWithHeight.push(json[i].job__); 429 } 430 } 431 } 432 } else { 433 writeSearchLog(' - no DOB Job Application results for this BIN\r\n'); 434 row = infoTable.insertRow(-1); 435 row.innerHTML = '<td>none found</td>'; 436 } 437 writeBuildingClass(buildingClassMap); 438} 439 440async function doDobNowSearch() { 441 442 function shortenDobNowStatus(dobNowStatus) { 443 let j = dobNowStatus.indexOf(' -'); 444 if (j > 2) { 445 return dobNowStatus.slice(0,j); 446 } 447 return dobNowStatus.replace('Certificate of Operation', 'Cert of Op'); 448 //check ?search=40.64242407021607 -73.98076415061952 for another lengthy status 449 } 450 451 let row = infoTable.insertRow(-1); 452 row.className = 'rowHead'; 453 row.innerHTML = '<td>DOB NOW Job</td><td>Type</td><td>Status</td><td>Date</td><td>Height</td><td id="dobNowLink" class="tdLink" id="dobNowLink"><a href="' + urlDobNow + '">open DOB NOW w/ BIN in clipboard</a></td>'; 454 const dobNowLink = document.getElementById('dobNowLink'); 455 dobNowLink.addEventListener('mouseup', handleDobNowLinkEvents, {capture: true}); 456 dobNowLink.addEventListener('click', handleDobNowLinkEvents, {capture: true}); 457 const dobNowJobApiQuery = 'https://data.cityofnewyork.us/resource/w9ak-ipjd.json?$select=distinct%20job_filing_number,house_no,street_name,borough,block,lot,job_type,filing_status,current_status_date,proposed_height,latitude,longitude&$where=bin=%27' + bin + '%27&$order=current_status_date'; 458 let json = await httpGetJson(dobNowJobApiQuery, '"DOB NOW: Build – Job Application Filings"'); 459 if (json.length > 0) { 460 if (json.length === 1) { 461 writeSearchLog(' - only one DOB NOW Job result for this BIN\r\n'); 462 } else { 463 writeSearchLog(' - ' + json.length + ' DOB NOW Job results for this BIN\r\n'); 464 } 465 let j = json.length - 1; 466 let needAddress = (addressDiv.innerHTML === ''); 467 let houseNumber = ''; 468 let street = '';
469 let boroCode = bin.slice(0,1); 470 let needBbl = (bblDiv.innerHTML === ''); 471 let jobLot = ''; 472 let jobBlock = ''; 473 let currentStatusDate = ''; 474 475 /* I don't have a particular goal with the sort order in the DOB NOW portion of the table, so I'll just show the newest jobs on top. The date is in a sortable format but the API can't do a descending sort, so this loop processes the rows in reverse order. */ 476 for (let i=0; i <= j; i++) { 477 478 if (needAddress) { 479 houseNumber = json[j-i].house_no ?? ''; 480 street = json[j-i].street_name ?? ''; 481 if (houseNumber !== '' && street !== '') { 482 writeSearchLog(' - showing address ' + houseNumber + ' ' + street + ', boro ' + boroCode + '\r\n'); 483 writeAddress(houseNumber, street, boroCode); 484 needAddress = false; 485 } 486 } 487 488 if (needBbl) { 489 jobBlock = json[j-i].block ?? ''; 490 jobLot = json[j-i].lot ?? ''; 491 if (jobBlock !== '' && jobLot !== '') { 492 jobBlock = jobBlock.padStart(5, '0'); 493 jobLot = jobLot.padStart(5, '0'); 494 writeBbl(boroCode, jobBlock, jobLot); 495 writeSearchLog(' - showing BBL ' + boroCode + jobBlock + jobLot + ' from result ' + (j-i) + '\r\n'); 496 needBbl = false; 497 } 498 } 499 500 if (markerLatLon === null) { 501 if ((json[j-i].latitude > 40) && (json[j-i].longitude < -73)) { 502 markerLatLon = [json[j-i].latitude, json[j-i].longitude]; 503 writeSearchLog(' - got latlon ' + json[j-i].latitude + ', ' + json[j-i].longitude + ' from result ' + i + '\r\n'); 504 } 505 } 506 507 if (typeof(json[j-i].job_filing_number) !== 'undefined') { 508 row = infoTable.insertRow(-1); 509 row.className = 'rowBg' + (i % 2); 510 if (typeof(json[j-i].current_status_date) === 'undefined') { 511 currentStatusDate = '?'; 512 } else { 513 currentStatusDate = json[j-i].current_status_date.slice(0,10); 514 } 515 row.innerHTML = '<td>' + json[j-i].job_filing_number + '</td><td>' + json[j-i].job_type + '</td><td>' + shortenDobNowStatus(json[j-i].filing_status) + '</td><td>' + currentStatusDate + '</td><td>' + formatHeight(json[j-i].proposed_height) + '</td>'; 516 } 517 } 518 } else { 519 writeSearchLog(' - no DOB NOW Job results for this BIN\r\n'); 520 row = infoTable.insertRow(-1); 521 row.innerHTML = '<td>none found</td>'; 522 } 523} 524 525 526async function doAddressSearch(searchText) { 527 /* For the address search, we'll use the NYC GeoSearch API (https://geosearch.planninglabs.nyc) 528 which parses freeform address search text and returns a list of the most likely matching 529 addresses with their associated BINs and other info. 530 531 It works well even with minimal input, eg "32 middagh" will return BIN 3001569 (32 MIDDAGH 532 STREET BROOKLYN) as the top ranked result without the need to specify "street" or "brooklyn" in 533 the search. This is unambiguous since there's only one road named Middagh in NYC, and only one 534 building on that road with housenumber 32. 535 536 A more ambiguous query, "32 cranberry", ranks 32 Cranberry Court in Staten Island first, and 537 then 32 Cranberry Street in Brooklyn. Nycaabs simply uses the top result, so if the Brooklyn 538 address is preferred, the search text can be made more explicit by adding "street" or "st". 539 540 Querying the API for "32 cranberry brooklyn" without the street suffix will still return the 541 Staten Island address first, however. This is new behavior in v2 of GeoSearch, which appears 542 to be much more reliant on the query containing a valid street suffix than v1 did. There are 543 definitely some upsides to the v2 API though. The old v1 would sometimes return unquestionably 544 incorrect results and, for certain addresses, would *never* rank the right result first no 545 matter how explicit the query text. 546 547 For NYC GeoSearch v1, Nycaabs had a multi-tier custom sort to deal with these edge cases. We 548 now use a much simpler sort that only compares the boro guessed from the parsed search text 549 to the boro of the returned properties. This enables us to search using eg "87 3rd av mn" 550 instead of need to spell out "87 3rd av manhattan" 551 552 1515 Park Place 553 Franklin Av BK 554 555 The API v2 is still very new and may have its own mysterious edge cases that need massaging. 556 Please raise a Github issue at https://github.com/jmapb/nycaabs/issues with examples of any 557 address searches that return the wrong property, thanks! 558 559 */ 560 561 function boroMatchRank(resultBin, searchBoro) { 562 if (searchBoro === 0) { 563 return 2000000; 564 } 565 if (resultBin.slice(0,1) === searchBoro.toString()) { 566 return 1000000; 567 } 568 return 9000000; 569 } 570
571 function streetMatchRank(resultStreet, searchStreet) { 572 if (searchStreet === '') { 573 return 40000; 574 } 575 if (searchStreet === resultStreet) { 576 return 10000; 577 } 578 if (resultStreet.includes(searchStreet)) { 579 return 20000; 580 } 581 if (searchStreet.includes(resultStreet)) { 582 return 30000; 583 } 584 return 90000; 585 } 586 587 function houseNumberMatchRank(resultHouseNumber, searchHouseNumber) { 588 if (searchHouseNumber === '') { 589 return 4000; 590 } 591 if (searchHouseNumber === resultHouseNumber) { 592 return 1000; 593 } 594 if (resultHouseNumber.includes(searchHouseNumber)) { 595 return 200000; 596 } 597 if (searchHouseNumber.includes(resultHouseNumber)) { 598 return 300000; 599 } 600 return 900000; 601 } 602 603 let parsedAddress = parseNycAddress(searchText); 604 writeSearchLog(' - parser "parse-nyc-address" found ' + JSON.stringify(parsedAddress)); 605 let searchBoroNum = parsedAddress.borough ?? 0; 606 let searchStreet = parsedAddress.street ?? ''; 607 let searchHouseNumber = parsedAddress.housenumber ?? ''; 608 609 //To-do if parseNycAddress() gives us street and boro, attempt a Geoservice search. 610 //If Geoservice fails us, fall back to NYC GeoSearch 611 612 const nycGeosearchApiQuery = 'https://geosearch.planninglabs.nyc/v2/search?text=' + encodeURIComponent(searchText); 613 let json = await httpGetJson(nycGeosearchApiQuery, '"NYC GeoSearch"'); 614 615 if (typeof json?.geocoding !== 'undefined') { 616 (json.geocoding?.errors ?? []).forEach (error => { 617 writeSearchLog(' - GeoSearch error "' + error + '"\r\n'); 618 }); 619 let parserDesc = 'an unspecified parser'; 620 if (typeof json.geocoding?.query?.parser !== 'undefined') { 621 parserDesc = 'parser "' + json.geocoding.query.parser + '"'; 622 } 623 if (typeof json.geocoding?.query?.parsed_text !== 'undefined') { 624 writeSearchLog(' - GeoSearch used ' + parserDesc + ', parsed address=' + JSON.stringify(json.geocoding.query.parsed_text) + ' \r\n'); 625 } else { 626 writeSearchLog(' - GeoSearch used ' + parserDesc + ', no parsed address was returned\r\n'); 627 } 628 } 629 630 let geosearchResults = json?.features ?? []; 631 if (geosearchResults.length === 0) { 632 writeSearchLog(' - no NYC GeoSearch results'); 633 } else { 634 if (geosearchResults.length === 1) { 635 writeSearchLog(' - only one NYC GeoSearch result'); 636 //document.getElementById('searchLogTextareaId').style.color = 'blue'; 637 } else { 638 for (let i = 0; i < geosearchResults.length; i++) { 639 geosearchResults[i].geosearch_rank = i; 640 geosearchResults[i].nycaabs_rank = i + boroMatchRank(geosearchResults[i].properties.addendum.pad.bin, searchBoroNum) 641 + streetMatchRank(geosearchResults[i].properties.street, searchStreet) 642 + houseNumberMatchRank(geosearchResults[i].properties.housenumber ?? '', searchHouseNumber); 643 } 644 json.features.sort(function(a, b) { return a.nycaabs_rank - b.nycaabs_rank; }); 645 if (geosearchResults[0].geosearch_rank === 0) { 646 writeSearchLog(' - ' + geosearchResults.length + ' NYC GeoSearch results, using result 0'); 647 } else { 648 writeSearchLog(' - ' + geosearchResults.length + ' NYC GeoSearch results, using result ' + geosearchResults[0].geosearch_rank + ' after custom sort'); 649 //document.getElementById('searchLogTextareaId').style.color = 'red'; 650 } 651 } 652 653 let gsBin = geosearchResults[0]?.properties?.addendum?.pad?.bin ?? ''; 654 let boroCode = gsBin.slice(0,1); 655 if (boroCode === searchBoroNum.toString()) { 656 writeSearchLog(', matches search boro\r\n'); 657 } else { 658 writeSearchLog(', no boro match\r\n'); 659 } 660 let houseNumber = geosearchResults[0]?.properties?.housenumber ?? ''; 661 let street = geosearchResults[0]?.properties?.street ?? ''; 662 let bbl = geosearchResults[0]?.properties?.addendum?.pad?.bbl ?? ''; 663 /* We also have geosearchResult.properties.borough but we don't need it since we're 664 setting the boro based on the first digit of the bin. If someday we want to show zip 665 codes, we can use geosearchResult.properties.postalcode but the quality of this field 666 is unknown. 667 */ 668 669 /* BIG TODO -- At this point it would be great to take the address components (maybe as 670 returned in the parsed_text structure above, or failing that from the top search result, 671 or failing that parse the text ourselves) and feed them into a "Function1A" query on the 672 NYC Planning Geoservice API https://geoservice.planning.nyc.gov/, which has important 673 advantages over the NYC GeoSearch API: 674 - First, it returns address ranges for all streets of multi-street buildings. (NYC 675 GeoSearch only returns the address range on the queried street.) 676 - Second, it gives additional address classification info (See "Address Types" at 677 http://a030-goat.nyc.gov/goat/Glossary for documentation.) 678 - Third, it can find recently updated addresses and BINs by accessing the Transitional 679 PAD file (TPAD). NYC GeoSearch uses the standard PAD file, which is only updated 680 quarterly. 681 682 If we could get decent results from the NYC Planning Geoservice API, we probably 683 wouldn't even need to sort or further examine the NYC GeoSearch results. (Curse these 684 very similar names!) However, access to NYC Planning Geoservice requires registration 685 and department approval, and will only work with a city-issued private API key. Since 686 this app currently uses client-side fetches for all API calls, keeping the API key 687 private wouldn't be possible. 688 689 There's a web app wrapper around the NYC Planning Geoservice API called "GOAT", 690 https://a030-goat.nyc.gov/goat. The GOAT app supports URL query parameters and is free 691 to use without credentials, but its cross-site scripting policy prevents us from screen- 692 scraping the results from our own web app. In theory we could attempt to work around 693 this with a CORS proxy but that's messy. (GOAT is also available as a downloadable 694 offline desktop app, but that version only uses the standard PAD file, not the TPAD 695 file.) 696 697 For now, we'll simply rely on the results from NYC GeoSearch with the following known 698 limitations: 699 - Slightly out-of-date data (PAD only, not TPAD) that will not reflect newer
700 developments and may return obsolete or temporary BINs instead of current ones. 701 - Incomplete housenumber range info, especially for multi-street buildings. 702 - Need custom sort to favor results that match the requested borough and street name, as 703 described in the comment at this top of this function. 704 */ 705 706 writeSearchLog(' - showing address ' + houseNumber + ' ' + street + ', boro ' + boroCode + '\r\n'); 707 writeAddress(houseNumber, street, boroCode); 708 709 const reMatch = bbl.match(bblRegex); 710 if (reMatch === null) { 711 writeSearchLog(' - not using invalid BBL ' + bbl + '\r\n'); 712 } else { 713 writeSearchLog(' - showing BBL ' + bbl + '\r\n'); 714 writeBbl(reMatch[1], reMatch[2], reMatch[3]); 715 } 716 717 if (validBin(gsBin)) { 718 writeSearchLog(' - showing BIN ' + gsBin + '\r\n'); 719 writeBin(gsBin); 720 await doBinSearch(gsBin); 721 } else { 722 writeSearchLog(' - showing invalid BIN ' + gsBin + ', deeper search impossible without a valid BIN\r\n'); 723 writeInvalidBin(gsBin); 724 } 725 if (((geosearchResults[0]?.geometry?.coordinates[0] ?? 0) < -73) && ((geosearchResults[0]?.geometry?.coordinates[1] ?? 0) > 40)) { 726 markerLatLon = [geosearchResults[0].geometry.coordinates[1], geosearchResults[0].geometry.coordinates[0]]; 727 writeSearchLog(' - got latlon ' + markerLatLon[0] + ', ' + markerLatLon[0] + ' from Geosearch\r\n'); 728 } 729 } 730} 731 732 733/* HTML OUTPUT FUNCTIONS */ 734 735function writeSearchLog(logText) { 736 document.getElementById('searchLogTextareaId').value += logText; 737} 738 739function clearSearchLog() { 740 document.getElementById('searchLogTextareaId').value = ''; 741} 742 743function writeAddress(housenumber, street, boroCode) { 744 let boroName = boros[parseInt(boroCode)-1]; 745 addressDiv.innerHTML = '<strong>Address</strong> ' + housenumber + ' ' + street + ' ' + boroName + ' <a href="' + constructUrlBisProfileAddress(housenumber, street, boroCode) + '">Search Address @ BIS</a> <a href="' + constructUrlGoat1A(housenumber, street, boroCode) + '">Search Address @ GOAT</a>'; 746} 747 748function writeFailedAddress() { 749 addressDiv.innerHTML = '<strong>Address</strong> Not found'; 750} 751 752function writeBin(wBin) { 753 binDiv.innerHTML = '<strong>BIN</strong> ' + wBin + ' <a href="' + constructUrlBisProfileBin(wBin) + '">Property Profile @ BIS</a> <a href="' + constructUrlGoatBN(wBin) + '">Search BIN @ GOAT</a> <a href="' + constructUrlOverpassTurbo(wBin) + '">Search BIN @ Overpass Turbo</a>'; 754} 755 756function writeInvalidBin(wBin) { 757 binDiv.innerHTML = '<strong>BIN</strong> ' + wBin + ' (invalid, search halted)'; 758} 759 760function writeBbl(boro, block, lot) { 761 bblDiv.innerHTML = '<strong>BBL</strong> ' + boro + block + lot + ' <a href="' + constructUrlBisBrowse(boro, block) + '">Browse Block @ BIS</a> <a href="' + constructUrlBisBrowse(boro, block, lot) + '">Browse BBL @ BIS</a> <a href="' + constructUrlZolaLot(boro, block, lot) + '">View BBL @ ZoLa</a>'; 762} 763 764function writeHpd(hpdId) { 765 let hpdHtml = 'Not found'; 766 if (hpdId !== '') { 767 hpdHtml = hpdId + ' <a href="' + constructUrlHpdId(hpdId) + '">Overview @ HPD</a>'; 768 } 769 hpdDiv.innerHTML = '<strong>HPD ID</strong> ' + hpdHtml; 770} 771 772function writeBuildingClass(buildingClassMap) { 773 774 function expandBuildingClass(code) { 775 const buildingClasses = { A0: 'CAPE COD', 776 A1: 'TWO STORIES - DETACHED SM OR MID', 777 A2: 'ONE STORY - PERMANENT LIVING QUARTER', 778 A3: 'LARGE SUBURBAN RESIDENCE', 779 A4: 'CITY RESIDENCE ONE FAMILY', 780 A5: 'ONE FAMILY ATTACHED OR SEMI-DETACHED', 781 A6: 'SUMMER COTTAGE', 782 A7: 'MANSION TYPE OR TOWN HOUSE', 783 A8: 'BUNGALOW COLONY - COOPERATIVELY OWNED LAND', 784 A9: 'MISCELLANEOUS ONE FAMILY', 785 B1: 'TWO FAMILY BRICK', 786 B2: 'TWO FAMILY FRAME', 787 B3: 'TWO FAMILY CONVERTED FROM ONE FAMILY', 788 B9: 'MISCELLANEOUS TWO FAMILY', 789 C0: 'THREE FAMILIES', 790 C1: 'OVER SIX FAMILIES WITHOUT STORES', 791 C2: 'FIVE TO SIX FAMILIES', 792 C3: 'FOUR FAMILIES', 793 C4: 'OLD LAW TENEMENT', 794 C5: 'CONVERTED DWELLINGS OR ROOMING HOUSE', 795 C6: 'WALK-UP COOPERATIVE', 796 C7: 'WALK-UP APT. OVER SIX FAMILIES WITH STORES', 797 C8: 'WALK-UP CO-OP; CONVERSION FROM LOFT/WAREHOUSE', 798 C9: 'GARDEN APARTMENTS', 799 CB: 'WALKUP APT LESS THAN 11 UNITS RESIDENTIAL', 800 CC: 'WALKUP CO-OP APT LESS THAN 11 UNITS RESIDENTIAL', 801 CM: 'MOBILE HOMES/TRAILER PARKS', 802 D0: 'ELEVATOR CO-OP; CONVERSION FROM LOFT/WAREHOUSE', 803 D1: 'ELEVATOR APT; SEMI-FIREPROOF WITHOUT STORES', 804 D2: 'ELEVATOR APT; ARTISTS IN RESIDENCE', 805 D3: 'ELEVATOR APT; FIREPROOF WITHOUT STORES', 806 D4: 'ELEVATOR COOPERATIVE', 807 D5: 'ELEVATOR APT; CONVERTED', 808 D6: 'ELEVATOR APT; FIREPROOF WITH STORES', 809 D7: 'ELEVATOR APT; SEMI-FIREPROOF WITH STORES', 810 D8: 'ELEVATOR APT; LUXURY TYPE', 811 D9: 'ELEVATOR APT; MISCELLANEOUS', 812 DB: 'ELEVATOR APT LESS THAN 11 UNITS RESIDENTIAL', 813 DC: 'ELEVATOR CO-OP APT LESS THAN 11 UNITS RESIDENTIAL', 814 E1: 'GENERAL WAREHOUSE', 815 E2: 'CONTRACTORS WAREHOUSE', 816 E7: 'SELF-STORAGE WAREHOUSES', 817 E9: 'MISCELLANEOUS WAREHOUSE', 818 F1: 'FACTORY; HEAVY MANUFACTURING - FIREPROOF', 819 F2: 'FACTORY; SPECIAL CONSTRUCTION - FIREPROOF', 820 F4: 'FACTORY; INDUSTRIAL SEMI-FIREPROOF', 821 F5: 'FACTORY; LIGHT MANUFACTURING', 822 F8: 'FACTORY; TANK FARM', 823 F9: 'FACTORY; INDUSTRIAL-MISCELLANEOUS', 824 G0: 'GARAGE; RESIDENTIAL TAX CLASS 1', 825 G1: 'ALL PARKING GARAGES', 826 G2: 'AUTO BODY/COLLISION OR AUTO REPAIR', 827 G3: 'GAS STATION WITH RETAIL STORE', 828 G4: 'GAS STATION WITH SERVICE/AUTO REPAIR', 829 G5: 'GAS STATION ONLY WITH/WITHOUT SMALL KIOSK', 830 G6: 'LICENSED PARKING LOT', 831 G7: 'UNLICENSED PARKING LOT', 832 G8: 'CAR SALES/RENTAL WITH SHOWROOM', 833 G9: 'MISCELLANEOUS GARAGE', 834 GU: 'CAR SALES OR RENTAL LOTS WITHOUT SHOWROOM', 835 GW: 'CAR WASH OR LUBRITORIUM FACILITY', 836 HB: 'BOUTIQUE HOTEL: 10-100 ROOMS, W/LUXURY FACILITIES, THEMED, STYLISH, W/FULL SVC ACCOMMODATIONS', 837 HH: 'HOSTELS- BED RENTALS IN DORMITORY-LIKE SETTINGS W/SHARED ROOMS & BATHROOMS', 838 HR: 'SRO- 1 OR 2 PEOPLE HOUSED IN INDIVIDUAL ROOMS IN MULTIPLE DWELLING AFFORDABLE HOUSING',
839 HS: 'EXTENDED STAY/SUITE: AMENITIES SIMILAR TO APT; TYPICALLY CHARGE WEEKLY RATES & LESS EXPENSIVE THAN FULL-SERVICE HOTEL', 840 H1: 'LUXURY HOTEL', 841 H2: 'FULL SERVICE HOTEL', 842 H3: 'LIMITED SERVICE HOTEL; MANY AFFILIATED WITH NATIONAL CHAIN', 843 H4: 'MOTEL', 844 H5: 'HOTEL; PRIVATE CLUB, LUXURY TYPE', 845 H6: 'APARTMENT HOTEL', 846 H7: 'APARTMENT HOTEL - COOPERATIVELY OWNED', 847 H8: 'DORMITORY', 848 H9: 'MISCELLANEOUS HOTEL', 849 I1: 'HOSPITAL, SANITARIUM, MENTAL INSTITUTION', 850 I2: 'INFIRMARY', 851 I3: 'DISPENSARY', 852 I4: 'HOSPITAL; STAFF FACILITY', 853 I5: 'HEALTH CENTER, CHILD CENTER, CLINIC', 854 I6: 'NURSING HOME', 855 I7: 'ADULT CARE FACILITY', 856 I9: 'MISCELLANEOUS HOSPITAL, HEALTH CARE FACILITY', 857 J1: 'THEATRE; ART TYPE LESS THAN 400 SEATS', 858 J2: 'THEATRE; ART TYPE MORE THAN 400 SEATS', 859 J3: 'MOTION PICTURE THEATRE WITH BALCONY', 860 J4: 'LEGITIMATE THEATRE, SOLE USE', 861 J5: 'THEATRE IN MIXED-USE BUILDING', 862 J6: 'TELEVISION STUDIO', 863 J7: 'OFF BROADWAY TYPE THEATRE', 864 J8: 'MULTIPLEX PICTURE THEATRE', 865 J9: 'MISCELLANEOUS THEATRE', 866 K1: 'ONE STORY RETAIL BUILDING', 867 K2: 'MULTI-STORY RETAIL BUILDING (2 OR MORE)', 868 K3: 'MULTI-STORY DEPARTMENT STORE', 869 K4: 'PREDOMINANT RETAIL WITH OTHER USES', 870 K5: 'STAND-ALONE FOOD ESTABLISHMENT', 871 K6: 'SHOPPING CENTER WITH OR WITHOUT PARKING', 872 K7: 'BANKING FACILITIES WITH OR WITHOUT PARKING', 873 K8: 'BIG BOX RETAIL: NOT AFFIXED & STANDING ON OWN LOT W/PARKING, E.G. COSTCO & BJS', 874 K9: 'MISCELLANEOUS STORE BUILDING', 875 L1: 'LOFT; OVER 8 STORIES (MID MANH. TYPE)', 876 L2: 'LOFT; FIREPROOF AND STORAGE TYPE WITHOUT STORES', 877 L3: 'LOFT; SEMI-FIREPROOF', 878 L8: 'LOFT; WITH RETAIL STORES OTHER THAN TYPE ONE', 879 L9: 'MISCELLANEOUS LOFT', 880 M1: 'CHURCH, SYNAGOGUE, CHAPEL', 881 M2: 'MISSION HOUSE (NON-RESIDENTIAL)', 882 M3: 'PARSONAGE, RECTORY', 883 M4: 'CONVENT', 884 M9: 'MISCELLANEOUS RELIGIOUS FACILITY', 885 N1: 'ASYLUM', 886 N2: 'HOME FOR INDIGENT CHILDREN, AGED, HOMELESS', 887 N3: 'ORPHANAGE', 888 N4: 'DETENTION HOUSE FOR WAYWARD GIRLS', 889 N9: 'MISCELLANEOUS ASYLUM, HOME', 890 O1: 'OFFICE ONLY - 1 STORY', 891 O2: 'OFFICE ONLY 2 - 6 STORIES', 892 O3: 'OFFICE ONLY 7 - 19 STORIES', 893 O4: 'OFFICE ONLY WITH OR WITHOUT COMM - 20 STORIES OR MORE', 894 O5: 'OFFICE WITH COMM - 1 TO 6 STORIES', 895 O6: 'OFFICE WITH COMM 7 - 19 STORIES', 896 O7: 'PROFESSIONAL BUILDINGS/STAND ALONE FUNERAL HOMES', 897 O8: 'OFFICE WITH APARTMENTS ONLY (NO COMM)', 898 O9: 'MISCELLANEOUS AND OLD STYLE BANK BLDGS.', 899 P1: 'CONCERT HALL', 900 P2: 'LODGE ROOM', 901 P3: 'YWCA, YMCA, YWHA, YMHA, PAL', 902 P4: 'BEACH CLUB', 903 P5: 'COMMUNITY CENTER', 904 P6: 'AMUSEMENT PLACE, BATH HOUSE, BOAT HOUSE', 905 P7: 'MUSEUM', 906 P8: 'LIBRARY', 907 P9: 'MISCELLANEOUS INDOOR PUBLIC ASSEMBLY', 908 Q1: 'PARKS/RECREATION FACILTY', 909 Q2: 'PLAYGROUND', 910 Q3: 'OUTDOOR POOL', 911 Q4: 'BEACH', 912 Q5: 'GOLF COURSE', 913 Q6: 'STADIUM, RACE TRACK, BASEBALL FIELD', 914 Q7: 'TENNIS COURT', 915 Q8: 'MARINA, YACHT CLUB', 916 Q9: 'MISCELLANEOUS OUTDOOR RECREATIONAL FACILITY', 917 RA: 'CONDO; CULTURAL, MEDICAL, EDUCATIONAL, ETC.', 918 RB: 'CONDO; OFFICE SPACE', 919 RG: 'CONDO; INDOOR PARKING', 920 RH: 'CONDO; HOTEL/BOATEL', 921 RK: 'CONDO; RETAIL SPACE', 922 RP: 'CONDO; OUTDOOR PARKING', 923 RR: 'CONDOMINIUM RENTALS', 924 RS: 'CONDO; NON-BUSINESS STORAGE SPACE', 925 RT: 'CONDO; TERRACES/GARDENS/CABANAS', 926 RW: 'CONDO; WAREHOUSE/FACTORY/INDUSTRIAL', 927 R0: 'SPECIAL CONDOMINIUM BILLING LOT', 928 R1: 'CONDO; RESIDENTIAL UNIT IN 2-10 UNIT BLDG.', 929 R2: 'CONDO; RESIDENTIAL UNIT IN WALK-UP BLDG.', 930 R3: 'CONDO; RESIDENTIAL UNIT IN 1-3 STORY BLDG.', 931 R4: 'CONDO; RESIDENTIAL UNIT IN ELEVATOR BLDG.', 932 R5: 'CONDO; MISCELLANEOUS COMMERCIAL', 933 R6: 'CONDO; RESID.UNIT OF 1-3 UNIT BLDG-ORIG CLASS 1', 934 R7: 'CONDO; COMML.UNIT OF 1-3 UNIT BLDG-ORIG CLASS 1', 935 R8: 'CONDO; COMML.UNIT OF 2-10 UNIT BLDG.', 936 R9: 'CO-OP WITHIN A CONDOMINIUM', 937 S0: 'PRIMARILY 1 FAMILY WITH 2 STORES OR OFFICES', 938 S1: 'PRIMARILY 1 FAMILY WITH 1 STORE OR OFFICE', 939 S2: 'PRIMARILY 2 FAMILY WITH 1 STORE OR OFFICE', 940 S3: 'PRIMARILY 3 FAMILY WITH 1 STORE OR OFFICE', 941 S4: 'PRIMARILY 4 FAMILY WITH 1 STORE OR OFFICE', 942 S5: 'PRIMARILY 5-6 FAMILY WITH 1 STORE OR OFFICE', 943 S9: 'SINGLE OR MULTIPLE DWELLING WITH STORES OR OFFICES', 944 T1: 'AIRPORT, AIRFIELD, TERMINAL', 945 T2: 'PIER, DOCK, BULKHEAD', 946 T9: 'MISCELLANEOUS TRANSPORTATION FACILITY', 947 U0: 'UTILITY COMPANY LAND AND BUILDING', 948 U1: 'BRIDGE, TUNNEL, HIGHWAY', 949 U2: 'GAS OR ELECTRIC UTILITY', 950 U3: 'CEILING RAILROAD', 951 U4: 'TELEPHONE UTILITY', 952 U5: 'COMMUNICATION FACILITY OTHER THAN TELEPHONE', 953 U6: 'RAILROAD - PRIVATE OWNERSHIP', 954 U7: 'TRANSPORTATION - PUBLIC OWNERSHIP', 955 U8: 'REVOCABLE CONSENT', 956 U9: 'MISCELLANEOUS UTILITY PROPERTY', 957 V0: 'ZONED RESIDENTIAL; NOT MANHATTAN', 958 V1: 'ZONED COMMERCIAL OR MANHATTAN RESIDENTIAL', 959 V2: 'ZONED COMMERCIAL ADJACENT TO CLASS 1 DWELLING: NOT MANHATTAN', 960 V3: 'ZONED PRIMARILY RESIDENTIAL; NOT MANHATTAN', 961 V4: 'POLICE OR FIRE DEPARTMENT', 962 V5: 'SCHOOL SITE OR YARD', 963 V6: 'LIBRARY, HOSPITAL OR MUSEUM', 964 V7: 'PORT AUTHORITY OF NEW YORK AND NEW JERSEY', 965 V8: 'NEW YORK STATE OR US GOVERNMENT', 966 V9: 'MISCELLANEOUS VACANT LAND', 967 W1: 'PUBLIC ELEMENTARY, JUNIOR OR SENIOR HIGH', 968 W2: 'PAROCHIAL SCHOOL, YESHIVA', 969 W3: 'SCHOOL OR ACADEMY', 970 W4: 'TRAINING SCHOOL', 971 W5: 'CITY UNIVERSITY', 972 W6: 'OTHER COLLEGE AND UNIVERSITY', 973 W7: 'THEOLOGICAL SEMINARY', 974 W8: 'OTHER PRIVATE SCHOOL', 975 W9: 'MISCELLANEOUS EDUCATIONAL FACILITY', 976 Y1: 'FIRE DEPARTMENT', 977 Y2: 'POLICE DEPARTMENT', 978 Y3: 'PRISON, JAIL, HOUSE OF DETENTION', 979 Y4: 'MILITARY AND NAVAL INSTALLATION', 980 Y5: 'DEPARTMENT OF REAL ESTATE', 981 Y6: 'DEPARTMENT OF SANITATION', 982 Y7: 'DEPARTMENT OF PORTS AND TERMINALS', 983 Y8: 'DEPARTMENT OF PUBLIC WORKS', 984 Y9: 'DEPARTMENT OF ENVIRONMENTAL PROTECTION', 985 Z0: 'TENNIS COURT, POOL, SHED, ETC.', 986 Z1: 'COURT HOUSE', 987 Z2: 'PUBLIC PARKING AREA', 988 Z3: 'POST OFFICE', 989 Z4: 'FOREIGN GOVERNMENT', 990 Z5: 'UNITED NATIONS', 991 Z7: 'EASEMENT', 992 Z8: 'CEMETERY', 993 Z9: 'OTHER MISCELLANEOUS' 994 }; 995 return buildingClasses[code] ?? 'Unknown building class ' + code; 996 } 997 998 let bcHtml = 'Not found'; 999 if (buildingClassMap.size === 1) { 1000 let bc = buildingClassMap.keys().next().value; 1001 bcHtml = '<abbr title="' + expandBuildingClass(bc) + '">' + bc + '</abbr>' 1002 } else if (buildingClassMap.size > 1) { 1003 /* Rare but possible for a single BIN to have multiple building class codes over time, eg 4554538. 1004 When this happens we'll list each followed by the most recent date it was used. */ 1005 bcHtml = Array.from(buildingClassMap).map((x) => '<abbr title="' + expandBuildingClass(x[0]) + '">' + x[0] + '</abbr>' + ' (' + x[1] + ')').join(', '); 1006 } 1007 buildingClassDiv.innerHTML = '<strong>Building Class</strong> ' + bcHtml; 1008} 1009 1010function clearIoElements() { 1011 addressDiv.innerHTML = ''; 1012 binDiv.innerHTML = ''; 1013 bblDiv.innerHTML = ''; 1014 hpdDiv.innerHTML = ''; 1015 buildingClassDiv.innerHTML = ''; 1016 addressRangeList.innerHTML = ''; 1017 const dobNowLink = document.getElementById('dobNowLink'); 1018 if (dobNowLink) { 1019 dobNowLink.removeEventListener('mouseup', handleDobNowLinkEvents, {capture: true}); 1020 dobNowLink.removeEventListener('click', handleDobNowLinkEvents, {capture: true}); 1021 } 1022 infoTable.innerHTML = ''; 1023} 1024 1025 1026/* LINK FUNCTIONS */ 1027 1028function constructUrlBisBrowse(boro, block, lot) { 1029 let url = 'https://a810-bisweb.nyc.gov/bisweb/PropertyBrowseByBBLServlet?allborough=' + boro + '&allblock=' + block; 1030 if (typeof lot !== 'undefined') { 1031 url += '&alllot=' + lot; 1032 } 1033 return url; 1034} 1035 1036function constructUrlBisJobs(cBin, filler) { 1037 let url = 'https://a810-bisweb.nyc.gov/bisweb/JobsQueryByLocationServlet?allbin=' + cBin; 1038 if (typeof filler !== 'undefined') { 1039 url += '&fillerdata=' + filler; 1040 } 1041 return url; 1042} 1043 1044function constructUrlBisProfileAddress(houseNumber, street, boroCode) { 1045 return 'https://a810-bisweb.nyc.gov/bisweb/PropertyProfileOverviewServlet?boro=' + boroCode + '&houseno=' + encodeURIComponent(houseNumber) + '&street=' + encodeURIComponent(street); 1046} 1047 1048function constructUrlBisProfileBin(cBin) { 1049 return 'https://a810-bisweb.nyc.gov/bisweb/PropertyProfileOverviewServlet?bin=' + cBin; 1050} 1051 1052function constructUrlGoat1A(houseNumber, street, boroCode) {
1053 return 'https://a030-goat.nyc.gov/goat/Function1A?borough=' + boroCode + '&street=' + encodeURIComponent(street) + '&address=' + encodeURIComponent(houseNumber); 1054} 1055 1056function constructUrlGoatBN(cBin) { 1057 return 'https://a030-goat.nyc.gov/goat/FunctionBN?bin=' + cBin; 1058} 1059 1060function constructUrlHpdId(hpdId) { 1061 return 'https://hpdonline.nyc.gov/hpdonline/building/' + hpdId + '/overview'; 1062} 1063 1064function constructUrlOverpassTurbo(cBin) { 1065 return 'https://overpass-turbo.eu/?Q=%7B%7BgeocodeArea%3Anyc%7D%7D-%3E.nyc%3B%0Anwr%5B%22nycdoitt%3Abin%22~' + cBin + '%5D(area.nyc)%3B%0Aout%20geom%3B&R'; 1066} 1067 1068function constructUrlZolaLot(boro, block, lot) { 1069 return 'https://zola.planning.nyc.gov/l/lot/' + boro + '/' + block + '/' + lot; 1070} 1071 1072async function handleDobNowLinkEvents(e) { 1073 e.preventDefault(); 1074 if ((e.type === 'click') || ((e.button ?? 0) === 1)) { 1075 await navigator.clipboard.writeText(bin); 1076 if (e.type === 'click') { 1077 window.open(urlDobNow, 'dobNowTab-' + bin); 1078 } 1079 } 1080} 1081 1082 1083/* FOOTPRINT FUNCTIONS */ 1084 1085function processFootprint(footprintIndex, footprintBin, heightInMeters) { 1086 /* Digest a downloaded building footprint and add our own custom properties: A bounding box, an 1087 OSM XML representation of the footprint, and, for single-polygon buildings, a JOSM "add_way" 1088 remote control command. */ 1089 1090 function xmlTagList(tags) { 1091 xmlTags = ''; 1092 for (const k in tags) { 1093 xmlTags += " <tag k='" + k + "' v='" + tags[k] + "' />\r\n"; 1094 } 1095 return xmlTags; 1096 } 1097 1098 function xmlNodeListForPolygon(polygon, nodes) { 1099 let xmlNodes = ''; 1100 for (const c of polygon) { 1101 xmlNodes += " <nd ref='" + nodes[c[1] + "' lon='" + c[0]] + "' />\r\n"; 1102 } 1103 return xmlNodes; 1104 } 1105 1106 let footprint = footprintJson[footprintIndex]; 1107 footprint.nycaabs_osm_xml = "<?xml version='1.0' encoding='UTF-8'?>\r\n<osm version='0.6' generator='NYCAABS'>\r\n"; 1108 footprint.nycaabs_bbox_left = -73; 1109 footprint.nycaabs_bbox_right = -75; 1110 footprint.nycaabs_bbox_top = 40; 1111 footprint.nycaabs_bbox_bottom = 42; 1112 let elementCounter = -1; 1113 let nodeKey = ''; 1114 const nodes = {}; 1115 const tags = {building: 'yes'}; 1116 tags['nycdoitt:bin'] = footprintBin; 1117 if (heightInMeters !== '') { 1118 tags['height'] = heightInMeters; 1119 } 1120 1121 for (const polygon of footprint.the_geom.coordinates[0]) { 1122 for (const c of polygon) { 1123 //Round coordinates to OSM-standard 7 decimal digits 1124 c[0] = c[0].toFixed(7); 1125 c[1] = c[1].toFixed(7); 1126 footprint.nycaabs_bbox_left = Math.min(footprint.nycaabs_bbox_left, c[0]); 1127 footprint.nycaabs_bbox_right = Math.max(footprint.nycaabs_bbox_right, c[0]); 1128 footprint.nycaabs_bbox_top = Math.max(footprint.nycaabs_bbox_top, c[1]); 1129 footprint.nycaabs_bbox_bottom = Math.min(footprint.nycaabs_bbox_bottom, c[1]); 1130 nodeKey = c[1] + "' lon='" + c[0]; 1131 //consider using a javascript map object with an array key instead of this hacky string key 1132 if (!(nodeKey in nodes)) { 1133 nodes[nodeKey] = elementCounter--; 1134 } 1135 } 1136 } 1137 1138 for (const n in nodes) { 1139 //Add each node definition to the OSM XML 1140 footprint.nycaabs_osm_xml += " <node id='" + nodes[n] + "' action='modify' visible='true' lat='" + n + "' />\r\n"; 1141 } 1142 1143 /* There are two different remote control commands that we can use to send a footprint to JOSM: 1144 "add_way" and "load_data". The "add_way" command can't handle multipolygon footprints (and 1145 there's no "add_relation" command) so we'll always use "load_data" for multipolygons, but for 1146 single-polygon footprints we'll use "add_way" because it has some advantages: 1147 - It only adds new nodes if neccessary, so JOSM will re-use any already loaded nodes at the 1148 same coordinates. This is convenient for connecting new footprints to previously-imported 1149 abutting footprints. Rarely it might also result in the new footprint connecting to non- 1150 building nodes, which could be problematic, but on balance this is still desired behavior. 1151 (In theory we could achieve similar behavior from "load_data" with a preliminary OSM API 1152 "map?bbox" call, swapping in the IDs of any existing nodes at the same locations. Possibly 1153 a topic for further investigation.) 1154 - With "add_way", the new way and tags are recorded in JOSM's command stack, so can be undone 1155 (ctrl-Z) if desired. The "load_data" command doesn't give us that courtesy, so accidentally 1156 loading an incorrect footprint can leave your JOSM data layer in an awkward state. 1157 So, if the polygon count is 1, we'll add a "nycaabs_josm_add" property to the footprint for 1158 the sendFootprintToJosm function to find. If that property isn't found, sendFootprintToJosm 1159 will send the OSM XML version of the footprint via "load_data". 1160 */ 1161 if (footprint.the_geom.coordinates[0].length === 1) { 1162 //Encode building footprint as a way 1163 footprint.nycaabs_osm_xml += " <way id='" + elementCounter + "' action='modify' visible='true'>\r\n"; 1164 footprint.nycaabs_osm_xml += xmlNodeListForPolygon(footprint.the_geom.coordinates[0][0], nodes); 1165 footprint.nycaabs_osm_xml += xmlTagList(tags); 1166 footprint.nycaabs_osm_xml += " </way>\r\n"; 1167 //Assemble the JOSM "add_way" remote control command 1168 footprint.nycaabs_josm_add = 'add_way?way=' + (footprint.the_geom.coordinates[0][0].map( x => x.reverse().join())).join(';') + '&addtags=' + (Object.entries(tags).map( t => t.join('='))).join('%7C'); 1169 } else { 1170 //Encode building footprint as a multipolygon relation with an outer way and one or more inner ways 1171 let xmlRelationMembers = ''; 1172 let role = 'outer'; 1173 for (const polygon of footprint.the_geom.coordinates[0]) { 1174 xmlRelationMembers += " <member type='way' ref='" + elementCounter + "' role='" + role + "' />\r\n"; 1175 footprint.nycaabs_osm_xml += " <way id='" + elementCounter-- + "' action='modify' visible='true'>\r\n"; 1176 footprint.nycaabs_osm_xml += xmlNodeListForPolygon(polygon, nodes); 1177 footprint.nycaabs_osm_xml += " </way>\r\n"; 1178 role = 'inner'; 1179 } 1180 footprint.nycaabs_osm_xml += " <relation id='" + elementCounter + "' action='modify' visible='true'>\r\n"; 1181 footprint.nycaabs_osm_xml += xmlRelationMembers; 1182 tags['type'] = 'multipolygon'; 1183 footprint.nycaabs_osm_xml += xmlTagList(tags); 1184 footprint.nycaabs_osm_xml += " </relation>\r\n"; 1185 } 1186 footprint.nycaabs_osm_xml += "</osm>\r\n"; 1187} 1188 1189async function sendFootprintToJosm(footprintIndex) { 1190 //First, send a "load_and_zoom" command for surrounding context 1191 let loadAndZoomUrl = 'http://localhost:8111/load_and_zoom?left=' + (footprintJson[footprintIndex].nycaabs_bbox_left - 0.0005) + '&right=' + (footprintJson[footprintIndex].nycaabs_bbox_right + 0.0005) + '&top='
1191+ (footprintJson[footprintIndex].nycaabs_bbox_top + 0.0004) + '&bottom=' + (footprintJson[footprintIndex].nycaabs_bbox_bottom - 0.0004); 1192 writeSearchLog('\r\nJOSM remote control command ' + loadAndZoomUrl + '\r\n'); 1193 await fetch(loadAndZoomUrl); 1194 1195 //Then send footprint with either "add_way" or "load_data" (see comments in processFootprint function) 1196 let sendFootprintUrl = 'http://localhost:8111/'; 1197 if ('nycaabs_josm_add' in footprintJson[footprintIndex]) { 1198 sendFootprintUrl += footprintJson[footprintIndex].nycaabs_josm_add; 1199 } else { 1200 const regex = /\s\s+/g; 1201 sendFootprintUrl += 'load_data?data=' + encodeURIComponent(footprintJson[footprintIndex].nycaabs_osm_xml.replaceAll(regex, '')); 1202 } 1203 1204 writeSearchLog('\r\nJOSM remote control command ' + sendFootprintUrl + '\r\n'); 1205 let response = await fetch(sendFootprintUrl); 1206 1207 //If the footprint delivery failed, retry once after one second 1208 if (!response.ok) { 1209 writeSearchLog('\r\n - retrying JOSM remote control command ' + sendFootprintUrl + '\r\n'); 1210 setTimeout(() => { fetch(sendFootprintUrl); }, 1000); 1211 } 1212} 1213 1214 1215/* SLIPPY MAP FUNCTIONS */ 1216 1217function slippyMapInit() { 1218 let haveTileLayer = false; 1219 if (slippyMap === null) { 1220 slippyMap = L.map('slippyMapId', 1221 {contextmenu: true, 1222 contextmenuItems: [{ 1223 text: 'Search here', 1224 callback: menuLatLonSearch 1225 }, '-', { 1226 text: 'View at OSM', 1227 callback: menuOsmView 1228 }, { 1229 text: 'Feature query at OSM', 1230 callback: menuOsmFeatureQuery 1231 }, { 1232 text: 'Reverse geocode at OSM', 1233 callback: menuOsmReverse 1234 }, { 1235 text: 'Reverse geocode at Nominatim', 1236 callback: menuNominatimReverse 1237 }, { 1238 text: 'Edit at OSM (iD)', 1239 callback: menuOsmEdit 1240 }, { 1241 text: 'Edit in RapiD', 1242 callback: menuRapidEdit 1243 }, { 1244 text: 'Edit in JOSM', 1245 callback: menuJosmEdit 1246 }, '-', { 1247 text: 'Cyclomedia imagery', 1248 callback: menuCyclomedia 1249 }, { 1250 text: 'Bing Streetside imagery', 1251 callback: menuBingStreetside 1252 }, { 1253 text: 'KartaView imagery', 1254 callback: menuKartaView 1255 }] 1256 }); 1257 } else { 1258 slippyMap.eachLayer(function (thisLayer) { 1259 //We want to keep the tile layer and delete everything else. There's probably 1260 //a better way, but checking for null attribution property works. 1261 if (thisLayer.getAttribution() === null) { 1262 slippyMap.removeLayer(thisLayer); 1263 } else { 1264 haveTileLayer = true; 1265 } 1266 }); 1267 } 1268 return haveTileLayer; 1269} 1270 1271function slippyMapDefault() { 1272 let haveTileLayer = slippyMapInit(); 1273 const defaultMapCenter = [40.73, -73.97]; 1274 const defaultMapZoom = 10; 1275 slippyMap.setView(defaultMapCenter, defaultMapZoom); 1276 if (!haveTileLayer) { 1277 slippyMapAddTileLayer(); 1278 } 1279} 1280 1281function slippyMapAddFootprint(footprintGeom) { 1282 let haveTileLayer = slippyMapInit(); 1283 const footprintGeoJson = L.geoJSON({'type': 'Feature', 'geometry': footprintGeom}); 1284 slippyMap.fitBounds(footprintGeoJson.getBounds()); 1285 if (!haveTileLayer) { 1286 slippyMapAddTileLayer(); 1287 } 1288 footprintGeoJson.addTo(slippyMap); 1289} 1290 1291function slippyMapAddMarker(latlon) { 1292 let haveTileLayer = slippyMapInit(); 1293 slippyMap.fitBounds([latlon]); 1294 if (!haveTileLayer) { 1295 slippyMapAddTileLayer(); 1296 } 1297 L.marker(latlon).addTo(slippyMap); 1298} 1299 1300function slippyMapAddTileLayer() { 1301 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19, attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'}).addTo(slippyMap); 1302} 1303 1304 1305/* SLIPPY MAP CONTEXT MENU CALLBACK FUNCTIONS */ 1306 1307function menuLatLonSearch(e) { 1308 searchInput.value = e.latlng.lat + ' ' + e.latlng.lng 1309 doSearch(); 1310} 1311 1312function menuOsmView(e) { 1313 window.open('https://www.openstreetmap.org/#map=19/' + e.latlng.lat + '/' + e.latlng.lng, '_blank'); 1314} 1315 1316function menuOsmEdit(e) { 1317 window.open('https://www.openstreetmap.org/edit#map=19/' + e.latlng.lat + '/' + e.latlng.lng, '_blank'); 1318} 1319 1320function menuRapidEdit(e) { 1321 window.open('https://rapideditor.org/edit#map=19/' + e.latlng.lat + '/' + e.latlng.lng, '_blank'); 1322} 1323 1324function menuJosmEdit(e) { 1325 fetch('http://localhost:8111/load_and_zoom?left=' + (e.latlng.lng - 0.0012) + '&right=' + (e.latlng.lng + 0.0012) + '&bottom=' + (e.latlng.lat - 0.0006) + '&top='
1325+ (e.latlng.lat + 0.0006)); 1326} 1327 1328function menuCyclomedia(e) { 1329 window.open('https://www.geocoder.nyc/streetview.html?lnglat=' + e.latlng.lng + ',' + e.latlng.lat, '_blank'); 1330} 1331 1332function menuBingStreetside(e) { 1333 window.open('https://www.bing.com/maps?style=x&cp=' + e.latlng.lat + '~' + e.latlng.lng, '_blank'); 1334} 1335 1336async function menuKartaView(e) { 1337 const kvApiQuery = 'https://api.openstreetcam.org/2.0/photo/?lat=' + e.latlng.lat + '&lng=' + e.latlng.lng + '&zoomLevel=16&join=sequence&orderBy=id&orderDirection=desc'; 1338 let kvJson = await httpGetJson(kvApiQuery, '"KartaView" latlon'); 1339 let trackId = kvJson?.result?.data[0]?.sequence?.id ?? 0; 1340 let photoId = kvJson?.result?.data[0]?.sequenceIndex ?? 0; 1341 if (trackId > 0 && photoId > 0) { 1342 window.open('https://kartaview.org/details/' + trackId + '/' + photoId + '/track-info', '_blank'); 1343 } else { 1344 window.alert('no KartaView imagery found for this location') 1345 } 1346} 1347 1348function menuOsmFeatureQuery(e) { 1349 window.open('https://www.openstreetmap.org/query?lat=' + e.latlng.lat + '&lon=' + e.latlng.lng + '#map=19/' + e.latlng.lat + '/' + e.latlng.lng, '_blank'); 1350} 1351 1352function menuOsmReverse(e) { 1353 window.open('https://www.openstreetmap.org/search?whereami=1&query=' + e.latlng.lat + '%2C' + e.latlng.lng + '#map=19/' + e.latlng.lat + '/' + e.latlng.lng, '_blank'); 1354} 1355 1356function menuNominatimReverse(e) { 1357 window.open('https://nominatim.openstreetmap.org/ui/reverse.html?lat=' + e.latlng.lat + '&lon=' + e.latlng.lng, '_blank'); 1358} 1359 1360 1361/* NETWORK FUNCTONS */ 1362 1363async function httpGetJson(getUrl, name) { 1364 writeSearchLog('\r\n' + name + ' query ' + getUrl + '\r\n'); 1365 let responseJson = {}; 1366 let errorMsg = ''; 1367 try { 1368 const response = await fetch(getUrl); 1369 if (!response.ok) { 1370 writeSearchLog(' - Fetch response was not OK, status ' + response.status + ' "' + response.statusText + '"\r\n'); 1371 errorMsg = 'Fetch response was not OK'; 1372 } 1373 responseJson = await response.json(); 1374 if (typeof(responseJson) !== 'undefined') { 1375 ['code', 'message', 'description'].forEach(warning => { 1376 if (typeof(responseJson[warning]) !== 'undefined') { 1377 writeSearchLog(' - json "' + warning + '"="' + responseJson[warning] + '"\r\n'); 1378 } 1379 }); 1380 } 1381 if (errorMsg !== '') { 1382 throw new Error(errorMsg); 1383 } 1384 } catch (e) { 1385 writeSearchLog(' - ERROR: ' + (e.message ?? '') + '\r\n'); 1386 } 1387 return responseJson; 1388} 1389 1390async function httpHeadContentLength(headUrl) { 1391 let contentLength = 0; 1392 const response = await fetch(headUrl, {method: 'HEAD'}); 1393 if (response.ok) { 1394 contentLength = Number(response.headers.get("content-length")); 1395 } 1396 return contentLength; 1397} 1398 1399async function httpJosmRemoteControl(josmCommand) { 1400 const josmUrl = 'http://localhost:8111/' + josmCommand; 1401 writeSearchLog(' - JOSM command ' + josmUrl + '\r\n'); 1402 try { 1403 const response = await fetch(josmUrl); 1404 if (response.ok) { 1405 return true; 1406 } else { 1407 writeSearchLog(' - JOSM response was not OK, status ' + response.status + ' "' + response.statusText + '"\r\n'); 1408 throw new Error('JOSM response was not OK'); 1409 } 1410 } catch (e) { 1411 writeSearchLog(' - ERROR: ' + (e.message ?? '') + '\r\n'); 1412 } 1413 return false; 1414} 1415 1416 1417/* MISC FUNCTIONS */ 1418//some of these might be better moved inside the relevant search functions 1419 1420function validBin(testBin) { 1421 const reMatch = testBin.match(/^[1-5]([0-9]{6})$/); 1422 return (reMatch !== null) && (reMatch[1] !== '000000'); 1423} 1424 1425function feetToMeters(feet) { 1426 let m = String(Math.round(feet * 3.048)); 1427 return m.slice(0,-1) + '.' + m.slice(-1); 1428} 1429 1430function formatHeight(feet, meters) { 1431 if (typeof feet === 'undefined') { 1432 return '?'; 1433 } 1434 return feet + 'ft (' + (meters ?? feetToMeters(feet)) + 'm)'; 1435}
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.