PageSourceSearch

https://jmapb.github.io/nycaabs/app.js

js jmapb.github.io collected 2026-10-03 09:24:02 UTC 74,310 bytes, 1,435 lines download raw bytes

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&nbsp;List&nbsp;@&nbsp;BIS</a> <a href="' + constructUrlBisJobs(bin, 'A') + '">Active&nbsp;Zoning&nbsp;Job&nbsp;@&nbsp;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&nbsp;Details&nbsp;@&nbsp;BIS</a> <a href="https://a810-bisweb.nyc.gov/bisweb/JobsZoningDocumentsServlet?passjobnumber=' + json[i].job__ + '&passdocnumber=01&allbin=' + bin + '">Zoning&nbsp;Docs&nbsp;@&nbsp;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&nbsp;Address&nbsp;@&nbsp;BIS</a> <a href="' + constructUrlGoat1A(housenumber, street, boroCode) + '">Search&nbsp;Address&nbsp;@&nbsp;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&nbsp;Profile&nbsp;@&nbsp;BIS</a> <a href="' + constructUrlGoatBN(wBin) + '">Search&nbsp;BIN&nbsp;@&nbsp;GOAT</a> <a href="' + constructUrlOverpassTurbo(wBin) + '">Search&nbsp;BIN&nbsp;@&nbsp;Overpass&nbsp;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&nbsp;Block&nbsp;@&nbsp;BIS</a> <a href="' + constructUrlBisBrowse(boro, block, lot) + '">Browse&nbsp;BBL&nbsp;@&nbsp;BIS</a> <a href="' + constructUrlZolaLot(boro, block, lot) + '">View&nbsp;BBL&nbsp;@&nbsp;ZoLa</a>';
762}
763
764function writeHpd(hpdId) {
765    let hpdHtml = 'Not found';
766    if (hpdId !== '') {
767        hpdHtml = hpdId + ' <a href="' + constructUrlHpdId(hpdId) + '">Overview&nbsp;@&nbsp;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: '&copy; <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.