WME GIS Layers

Adds GIS layers in WME

  1. /* eslint-disable camelcase */
  2. /* eslint-disable brace-style, curly, nonblock-statement-body-position, no-template-curly-in-string, func-names */
  3. // ==UserScript==
  4. // @name WME GIS Layers
  5. // @namespace https://greasyfork.org/users/45389
  6. // @version 2025.08.10.00
  7. // @description Adds GIS layers in WME
  8. // @author MapOMatic / JS55CT
  9. // @match *://*.waze.com/*editor*
  10. // @exclude *://*.waze.com/user/editor*
  11. // @exclude *://*.waze.com/editor/sdk/*
  12. // @require https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
  13. // @require https://cdn.jsdelivr.net/npm/@turf/turf@7/turf.min.js
  14. // @require https://update.greasyfork.org/scripts/506614/1441195/ESTreeProcessor.js
  15. // @require https://update.greasyfork.org/scripts/509664/WME%20Utils%20-%20Bootstrap.js
  16. // @require https://update.greasyfork.org/scripts/516445/1480246/Make%20GM%20xhr%20more%20parallel%20again.js
  17. // @require https://update.greasyfork.org/scripts/542477/1623802/wmeGisLBBOX.js
  18. // @connect greasyfork.org
  19. // @connect github.io
  20. // @grant GM_xmlhttpRequest
  21. // @grant GM_info
  22. // @grant GM_setClipboard
  23. // @license GNU GPLv3
  24. // @contributionURL https://github.com/WazeDev/Thank-The-Authors
  25. // @connect *
  26. // @connect tigerweb.geo.census.gov
  27. // @connect 136.234.13.165
  28. // @connect 216.167.160.20
  29. // @connect 35.172.145.31
  30. // @connect 52.37.30.30
  31. // @connect 54.213.14.253
  32. // @connect 72.10.206.73
  33. // @connect a2maps.a2gov.org
  34. // @connect adairgis.integritygis.com
  35. // @connect agis.charlottecountyfl.gov
  36. // @connect ago.clarkcountyohio.gov
  37. // @connect agomaps.larimer.org
  38. // @connect ags.agdmaps.com
  39. // @connect ags.bhamaps.com
  40. // @connect ags.kitsap.gov
  41. // @connect ags.myokaloosa.com
  42. // @connect ags.roseville.ca.us
  43. // @connect ags1.wgxtreme.com
  44. // @connect ags2maps.srcity.org
  45. // @connect ags3.scgov.net
  46. // @connect aldotgis.dot.state.al.us
  47. // @connect alleganygis.allconet.org
  48. // @connect alphagis.alpharetta.ga.us
  49. // @connect andrewgis.integritygis.com
  50. // @connect anrmaps.vermont.gov
  51. // @connect ansoncountygis.com
  52. // @connect api.milton.ca
  53. // @connect apnsgis1.apsu.edu
  54. // @connect apnsgis4.apsu.edu
  55. // @connect app.mdt.mt.gov
  56. // @connect apps.alamance-nc.com
  57. // @connect apps.fs.usda.gov
  58. // @connect apps.lickingcounty.gov
  59. // @connect apps.saltlakecounty.gov
  60. // @connect apps.vernoncounty.org
  61. // @connect apps.wyoroad.info
  62. // @connect arcgis-morrowarcgis-1015369042.us-east-1.elb.amazonaws.com
  63. // @connect arcgis-web.chinohills.org
  64. // @connect arcgis.atlantaregional.com
  65. // @connect arcgis.c3gov.com
  66. // @connect arcgis.cityofcapegirardeau.org
  67. // @connect arcgis.cityofwatsonville.org
  68. // @connect arcgis.clearfieldco.org
  69. // @connect arcgis.co.beltrami.mn.us
  70. // @connect arcgis.co.henry.ga.us
  71. // @connect arcgis.co.lancaster.pa.us
  72. // @connect arcgis.forneytx.gov
  73. // @connect arcgis.gis.lacounty.gov
  74. // @connect arcgis.kingsporttn.gov
  75. // @connect arcgis.leaguecitytx.gov
  76. // @connect arcgis.lewiscountywa.gov
  77. // @connect arcgis.mobile311.com
  78. // @connect arcgis.racinecounty.com
  79. // @connect arcgis.tampagov.net
  80. // @connect arcgis.tuscco.com
  81. // @connect arcgis.vgsi.com
  82. // @connect arcgis.water.nv.gov
  83. // @connect arcgis.waxahachie.com
  84. // @connect arcgis.yumacountyaz.gov
  85. // @connect arcgis4.roktech.net
  86. // @connect arcgis5.roktech.net
  87. // @connect arcgisce2.co.valencia.nm.us
  88. // @connect arcgisserver.digital.mass.gov
  89. // @connect arcgisserver.lincolncounty.org
  90. // @connect arcgisserver.maine.gov
  91. // @connect arcgisserver2.morpc.org
  92. // @connect arcgissrv.cityofbartlesville.org
  93. // @connect arcgiswap01.ci.temple.tx.us
  94. // @connect arcgisweb.carteretcountync.gov
  95. // @connect arcgisweb.countyofnewaygo.com
  96. // @connect arcgisweb.welland.ca
  97. // @connect arcmobile.co.albany.wy.us
  98. // @connect arcportal.florenceco.org
  99. // @connect arcserv.co.washington.ar.us
  100. // @connect arcserver.madisoncountyky.us
  101. // @connect arcserver2.oconeesc.com
  102. // @connect arcweb.hcad.org
  103. // @connect ardmoregis.ardmorecity.org
  104. // @connect arlgis.arlingtonva.us
  105. // @connect atchisongis.integritygis.com
  106. // @connect atlas.co.chelan.wa.us
  107. // @connect atlas.geoportalmaps.com
  108. // @connect atlas.unioncountync.gov
  109. // @connect audraingis.integritygis.com
  110. // @connect batesgis.integritygis.com
  111. // @connect bcgis.baltimorecountymd.gov
  112. // @connect bcgis.brunswickcountync.gov
  113. // @connect bcgishub.broward.org
  114. // @connect bcmaps.bradfordco.org
  115. // @connect bentongis.integritygis.com
  116. // @connect biamaps.geoplatform.gov
  117. // @connect bocagis.ci.boca-raton.fl.us
  118. // @connect bonneville.esriemcs.com
  119. // @connect bpagis.bossierparish.org
  120. // @connect bryangis.bryan-county.org
  121. // @connect buchanangis.integritygis.com
  122. // @connect butlergis.integritygis.com
  123. // @connect c39gisserver.co.richland.nd.us
  124. // @connect ca.dep.state.fl.us
  125. // @connect cagisonline.hamilton-co.org
  126. // @connect calmaps.co.calumet.wi.us
  127. // @connect caltrans-gis.dot.ca.gov
  128. // @connect cama.shelbycountyauditors.com
  129. // @connect camdengis.integritygis.com
  130. // @connect carto.nationalmap.gov
  131. // @connect cassweb.casscountymn.gov
  132. // @connect cceo.co.comal.tx.us
  133. // @connect ccmap.cccounty.us
  134. // @connect cecilmaps.org
  135. // @connect charitongis.integritygis.com
  136. // @connect christiangis.integritygis.com
  137. // @connect cloud.longviewtexas.gov
  138. // @connect cloudgis.bonnercountyid.gov
  139. // @connect co.knox.il.us
  140. // @connect coagisweb.cabq.gov
  141. // @connect com.blountgis.org
  142. // @connect concordgis.ci.concord.ca.us
  143. // @connect conservationgis.alabama.gov
  144. // @connect coopergis.integritygis.com
  145. // @connect covgis.cityofvacaville.com
  146. // @connect coweta-gis-web.coweta.ga.us
  147. // @connect cowlitzgis.net
  148. // @connect crgis.cedar-rapids.org
  149. // @connect cteco.uconn.edu
  150. // @connect cty-gis-web.co.humboldt.ca.us
  151. // @connect cw.townofclaytonnc.org
  152. // @connect dadegis.integritygis.com
  153. // @connect dallasgis.integritygis.com
  154. // @connect data.calgary.ca
  155. // @connect data.cityofchicago.org
  156. // @connect data.ct.gov
  157. // @connect data.edmonton.ca
  158. // @connect data.novascotia.ca
  159. // @connect data.wsdot.wa.gov
  160. // @connect data1.digitaldataservices.com
  161. // @connect dc-web-2.co.douglas.mn.us
  162. // @connect dcgis.dekalbcountyga.gov
  163. // @connect dcimapapps.countyofdane.com
  164. // @connect dekalbgis.integritygis.com
  165. // @connect delta.co.clatsop.or.us
  166. // @connect dev.wilsonvillemaps.com
  167. // @connect doniphangis.integritygis.com
  168. // @connect dotapp9.dot.state.mn.us
  169. // @connect douglasgis.integritygis.com
  170. // @connect dtdapps.coloradodot.info
  171. // @connect dungis.dunwoodyga.gov
  172. // @connect dunklingis.integritygis.com
  173. // @connect egis.baltimorecity.gov
  174. // @connect egis.pinellas.gov
  175. // @connect elb.elevatemaps.io
  176. // @connect emapsplus.com
  177. // @connect enigma.accgov.com
  178. // @connect enterprise.firstmap.delaware.gov
  179. // @connect eoc.franklin-gov.com
  180. // @connect epv.ci.juneau.ak.us
  181. // @connect eservices.co.crook.or.us
  182. // @connect essex-gis.co.essex.ny.us
  183. // @connect explore.opelika-al.gov
  184. // @connect fcgis.franklincountypa.gov
  185. // @connect feature.geographic.texas.gov
  186. // @connect feature.tnris.org
  187. // @connect fieldstone.orangecountync.gov
  188. // @connect fragis.fra.dot.gov
  189. // @connect fremontgis.com
  190. // @connect gasconadegis.integritygis.com
  191. // @connect gateway.maps.rlid.org
  192. // @connect gcgis.guilfordcountync.gov
  193. // @connect geaugarealink.co.geauga.oh.us
  194. // @connect geo.co.butler.pa.us
  195. // @connect geo.co.harrison.ms.us
  196. // @connect geo.dentoncad.com
  197. // @connect geo.forsythco.com
  198. // @connect geo.friscotexas.gov
  199. // @connect geo.lloydminster.ca
  200. // @connect geo.oit.ohio.gov
  201. // @connect geo.sandag.org
  202. // @connect geo.sanjoseca.gov
  203. // @connect geo.skagitcountywa.gov
  204. // @connect geo.statcan.gc.ca
  205. // @connect geo.tompkins-co.org
  206. // @connect geo.vbgov.com
  207. // @connect geo1.oit.ohio.gov
  208. // @connect geo2.co.dodge.wi.us
  209. // @connect geodata.hawaii.gov
  210. // @connect geodata.md.gov
  211. // @connect geodata.sarpy.com
  212. // @connect geodataportal.net
  213. // @connect geonb.snb.ca
  214. // @connect geoportal.kelowna.ca
  215. // @connect geopower.jws.com
  216. // @connect geospatial.alberta.ca
  217. // @connect geoweb.martin.fl.us
  218. // @connect geoweb02.ci.richmond.ca.us
  219. // @connect gis-2.warrencountyny.gov
  220. // @connect gis-erd-der.gnb.ca
  221. // @connect gis-server.co.becker.mn.us
  222. // @connect gis-server.co.montezuma.co.us
  223. // @connect gis.aacounty.org
  224. // @connect gis.abilenetx.com
  225. // @connect gis.adamscounty.org
  226. // @connect gis.addisontx.gov
  227. // @connect gis.aecomonline.net
  228. // @connect gis.allegancounty.org
  229. // @connect gis.allencountyohio.com
  230. // @connect gis.apachejunctionaz.gov
  231. // @connect gis.arapahoegov.com
  232. // @connect gis.arkansas.gov
  233. // @connect gis.ashecountygov.com
  234. // @connect gis.ashevillenc.gov
  235. // @connect gis.atlantaga.gov
  236. // @connect gis.auburnalabama.org
  237. // @connect gis.auglaizecounty.org
  238. // @connect gis.azdot.gov
  239. // @connect gis.bakersfieldcity.us
  240. // @connect gis.baycountyfl.gov
  241. // @connect gis.beaufortcountysc.gov
  242. // @connect gis.beaumonttexas.gov
  243. // @connect gis.bentoncountyar.gov
  244. // @connect gis.berkeleycountysc.gov
  245. // @connect gis.bigstonecounty.gov
  246. // @connect gis.bladenco.org
  247. // @connect gis.blairco.org
  248. // @connect gis.blm.gov
  249. // @connect gis.blueearthcountymn.gov
  250. // @connect gis.bransonmo.gov
  251. // @connect gis.brevardfl.gov
  252. // @connect gis.browncountywi.gov
  253. // @connect gis.buncombecounty.org
  254. // @connect gis.burkenc.org
  255. // @connect gis.burleighco.com
  256. // @connect gis.buttecounty.net
  257. // @connect gis.caldwellcountync.org
  258. // @connect gis.calhouncounty.org
  259. // @connect gis.campbellca.gov
  260. // @connect gis.campbellcountywy.gov
  261. // @connect gis.carboncounty.com
  262. // @connect gis.cayugacounty.us
  263. // @connect gis.cccounty.us
  264. // @connect gis.ccgisonline.com
  265. // @connect gis.ccpa.net
  266. // @connect gis.cedarfalls.com
  267. // @connect gis.cedarhilltx.com
  268. // @connect gis.cherokeega.com
  269. // @connect gis.chestermere.ca
  270. // @connect gis.chippewa.mn
  271. // @connect gis.chisagocountymn.gov
  272. // @connect gis.ci.janesville.wi.us
  273. // @connect gis.ci.mcminnville.or.us
  274. // @connect gis.ci.waco.tx.us
  275. // @connect gis.citruspa.org
  276. // @connect gis.cityofaikensc.gov
  277. // @connect gis.cityofberkeley.info
  278. // @connect gis.cityofboston.gov
  279. // @connect gis.cityofdenton.com
  280. // @connect gis.cityofirvine.org
  281. // @connect gis.cityofmiddletown.com
  282. // @connect gis.cityofmoore.com
  283. // @connect gis.cityofsanmateo.org
  284. // @connect gis.cityofwestsacramento.org
  285. // @connect gis.clevelandtn.gov
  286. // @connect gis.cmpdd.org
  287. // @connect gis.co.benton.or.us
  288. // @connect gis.co.berks.pa.us
  289. // @connect gis.co.carlton.mn.us
  290. // @connect gis.co.carver.mn.us
  291. // @connect gis.co.clarion.pa.us
  292. // @connect gis.co.cumberland.nc.us
  293. // @connect gis.co.door.wi.us
  294. // @connect gis.co.douglas.or.us
  295. // @connect gis.co.eau-claire.wi.us
  296. // @connect gis.co.fairfield.oh.us
  297. // @connect gis.co.fillmore.mn.us
  298. // @connect gis.co.grand.co.us
  299. // @connect gis.co.grant.mn.us
  300. // @connect gis.co.grant.wi.gov
  301. // @connect gis.co.green-lake.wi.us
  302. // @connect gis.co.hubbard.mn.us
  303. // @connect gis.co.isanti.mn.us
  304. // @connect gis.co.josephine.or.us
  305. // @connect gis.co.kittitas.wa.us
  306. // @connect gis.co.linn.or.us
  307. // @connect gis.co.mille-lacs.mn.us
  308. // @connect gis.co.nezperce.id.us
  309. // @connect gis.co.oneida.wi.us
  310. // @connect gis.co.pepin.wi.us
  311. // @connect gis.co.pierce.wi.us
  312. // @connect gis.co.polk.mn.us
  313. // @connect gis.co.richland.wi.us
  314. // @connect gis.co.roseau.mn.us
  315. // @connect gis.co.sangamon.il.us
  316. // @connect gis.co.sauk.wi.us
  317. // @connect gis.co.sherburne.mn.us
  318. // @connect gis.co.stearns.mn.us
  319. // @connect gis.co.stevens.mn.us
  320. // @connect gis.co.tuscarawas.oh.us
  321. // @connect gis.co.wadena.mn.us
  322. // @connect gis.co.waseca.mn.us
  323. // @connect gis.co.waushara.wi.us
  324. // @connect gis.co.ym.mn.gov
  325. // @connect gis.colorado.gov
  326. // @connect gis.coloradosprings.gov
  327. // @connect gis.columbiacountyga.gov
  328. // @connect gis.columbiacountymaps.com
  329. // @connect gis.columbiasc.gov
  330. // @connect gis.columbusga.org
  331. // @connect gis.concordnh.gov
  332. // @connect gis.cookeville-tn.org
  333. // @connect gis.corvallisoregon.gov
  334. // @connect gis.cosb.us
  335. // @connect gis.countyofriverside.us
  336. // @connect gis.cowleycounty.org
  337. // @connect gis.cranstonri.org
  338. // @connect gis.cravencountync.gov
  339. // @connect gis.crcog.org
  340. // @connect gis.crookcounty.wy.gov
  341. // @connect gis.crowwing.us
  342. // @connect gis.cstx.gov
  343. // @connect gis.cuyahogacounty.us
  344. // @connect gis.danville-va.gov
  345. // @connect gis.dauphincounty.org
  346. // @connect gis.deerparktx.gov
  347. // @connect gis.dekalbcountyga.gov
  348. // @connect gis.delcopa.gov
  349. // @connect gis.dentoncounty.gov
  350. // @connect gis.districtiii.org
  351. // @connect gis.dogis.org
  352. // @connect gis.donaanacounty.org
  353. // @connect gis.dot.nv.gov
  354. // @connect gis.dot.state.oh.us
  355. // @connect gis.douglascountyks.org
  356. // @connect gis.dubuquecounty.us
  357. // @connect gis.dupageco.org
  358. // @connect gis.duplincountync.com
  359. // @connect gis.dutchessny.gov
  360. // @connect gis.eastgreenwichri.com
  361. // @connect gis.edgecombecountync.gov
  362. // @connect gis.edmondok.gov
  363. // @connect gis.elkocountynv.net
  364. // @connect gis.elpasotexas.gov
  365. // @connect gis.emmetcounty.org
  366. // @connect gis.eriecountypa.gov
  367. // @connect gis.fortlauderdale.gov
  368. // @connect gis.franklincountyohio.gov
  369. // @connect gis.fultoncountyoh.com
  370. // @connect gis.fwb.org
  371. // @connect gis.fwp.mt.gov
  372. // @connect gis.gallatin.mt.gov
  373. // @connect gis.gallupnm.us
  374. // @connect gis.garrettcounty.org
  375. // @connect gis.gastongov.com
  376. // @connect gis.gcrc.org
  377. // @connect gis.gilacountyaz.gov
  378. // @connect gis.gocolumbiamo.com
  379. // @connect gis.goshencounty.org
  380. // @connect gis.gptx.org
  381. // @connect gis.grandcountyutah.net
  382. // @connect gis.greenecountyohio.gov
  383. // @connect gis.greenegovernment.com
  384. // @connect gis.greensboro-nc.gov
  385. // @connect gis.gscplanning.com
  386. // @connect gis.haldimandcounty.ca
  387. // @connect gis.hardeecounty.net
  388. // @connect gis.harnett.org
  389. // @connect gis.hartford.gov
  390. // @connect gis.hawaiicounty.gov
  391. // @connect gis.hcpafl.org
  392. // @connect gis.hennepin.us
  393. // @connect gis.huntingtonbeachca.gov
  394. // @connect gis.iberiagov.net
  395. // @connect gis.indot.in.gov
  396. // @connect gis.interdev.com
  397. // @connect gis.iowadot.gov
  398. // @connect gis.itd.idaho.gov
  399. // @connect gis.jacksonnc.org
  400. // @connect gis.jccal.org
  401. // @connect gis.johnson-county.com
  402. // @connect gis.johnsoncitytn.org
  403. // @connect gis.kalamazoocity.org
  404. // @connect gis.kanawhacountyassessor.com
  405. // @connect gis.kaufmancounty.net
  406. // @connect gis.kcgov.us
  407. // @connect gis.kcmn.us
  408. // @connect gis.kentcountyde.gov
  409. // @connect gis.kentcountymi.gov
  410. // @connect gis.kleinfelder.com
  411. // @connect gis.lacrossecounty.org
  412. // @connect gis.lafayettecountywi.org
  413. // @connect gis.lakecountyfl.gov
  414. // @connect gis.lakecountyohio.gov
  415. // @connect gis.lapazcountyaz.org
  416. // @connect gis.laplata.co.us
  417. // @connect gis.lasallecounty.org
  418. // @connect gis.latah.id.us
  419. // @connect gis.leecountyil.com
  420. // @connect gis.lehighcounty.org
  421. // @connect gis.leoc.net
  422. // @connect gis.lethbridge.ca
  423. // @connect gis.lincoln.ne.gov
  424. // @connect gis.littleelm.org
  425. // @connect gis.livingstoncounty.us
  426. // @connect gis.lja.com
  427. // @connect gis.lojic.org
  428. // @connect gis.losalamosnm.us
  429. // @connect gis.luzernecounty.org
  430. // @connect gis.lyco.org
  431. // @connect gis.lyon-county.org
  432. // @connect gis.macombgov.org
  433. // @connect gis.maconnc.org
  434. // @connect gis.maderacounty.com
  435. // @connect gis.marinpublic.com
  436. // @connect gis.marionfl.org
  437. // @connect gis.masoncountywa.gov
  438. // @connect gis.massdot.state.ma.us
  439. // @connect gis.mbakerintl.com
  440. // @connect gis.mcgtn.org
  441. // @connect gis.mckeancountypa.gov
  442. // @connect gis.mcohio.org
  443. // @connect gis.mendocinocounty.org
  444. // @connect gis.mercercountypa.gov
  445. // @connect gis.mesaaz.gov
  446. // @connect gis.mifflincountypa.gov
  447. // @connect gis.minnehahacounty.org
  448. // @connect gis.miottawa.org
  449. // @connect gis.missoulacounty.us
  450. // @connect gis.modestogov.com
  451. // @connect gis.mono.ca.gov
  452. // @connect gis.montgomeryal.gov
  453. // @connect gis.moorecountync.gov
  454. // @connect gis.moosejaw.ca
  455. // @connect gis.mytoddcounty.com
  456. // @connect gis.napa.ca.gov
  457. // @connect gis.nashcountync.gov
  458. // @connect gis.nassaucountyny.gov
  459. // @connect gis.nccde.org
  460. // @connect gis.ne.gov
  461. // @connect gis.neccog.org
  462. // @connect gis.newedgeservices.com
  463. // @connect gis.newhavenct.gov
  464. // @connect gis.nhcgov.com
  465. // @connect gis.niagaracounty.com
  466. // @connect gis.nola.gov
  467. // @connect gis.norrycopa.net
  468. // @connect gis.northamptoncounty.org
  469. // @connect gis.odot.state.or.us
  470. // @connect gis.ohiodnr.gov
  471. // @connect gis.okc.gov
  472. // @connect gis.orangecountygov.com
  473. // @connect gis.orangecountyva.gov
  474. // @connect gis.orrsc.com
  475. // @connect gis.osceola.org
  476. // @connect gis.outagamie.org
  477. // @connect gis.owensboro.org
  478. // @connect gis.pandai.com
  479. // @connect gis.pendercountync.gov
  480. // @connect gis.pendoreilleco.org
  481. // @connect gis.penndot.gov
  482. // @connect gis.penndot.pa.gov
  483. // @connect gis.peoriacounty.gov
  484. // @connect gis.personcountync.gov
  485. // @connect gis.pgatlas.com
  486. // @connect gis.pikepa.org
  487. // @connect gis.pinal.gov
  488. // @connect gis.pittcountync.gov
  489. // @connect gis.pittsburgca.gov
  490. // @connect gis.polk-county.net
  491. // @connect gis.popecountymn.gov
  492. // @connect gis.port-orange.org
  493. // @connect gis.pottcounty-ia.gov
  494. // @connect gis.princeedwardisland.ca
  495. // @connect gis.putnam-fl.com
  496. // @connect gis.qac.org
  497. // @connect gis.qualicumbeach.com
  498. // @connect gis.randolphcountync.gov
  499. // @connect gis.rapides911.org
  500. // @connect gis.rcgov.org
  501. // @connect gis.rdck.bc.ca
  502. // @connect gis.renvillecountymn.com
  503. // @connect gis.rileycountyks.gov
  504. // @connect gis.rocklin.ca.us
  505. // @connect gis.rowancountync.gov
  506. // @connect gis.rrnm.gov
  507. // @connect gis.rtcsnv.com
  508. // @connect gis.rutherfordcountync.gov
  509. // @connect gis.sanjuanco.com
  510. // @connect gis.santa-clarita.com
  511. // @connect gis.santacruzcounty.us
  512. // @connect gis.santamonica.gov
  513. // @connect gis.saskatchewan.ca
  514. // @connect gis.sawyerwi.org
  515. // @connect gis.sccwi.gov
  516. // @connect gis.shastacounty.gov
  517. // @connect gis.sheboygancounty.com
  518. // @connect gis.shelbycountytn.gov
  519. // @connect gis.showmeboone.com
  520. // @connect gis.siouxfalls.gov
  521. // @connect gis.slocounty.ca.gov
  522. // @connect gis.sncoapps.us
  523. // @connect gis.southkingstownri.com
  524. // @connect gis.steele.mn
  525. // @connect gis.stlouiscountymn.gov
  526. // @connect gis.sullivanny.us
  527. // @connect gis.sumtercountyfl.gov
  528. // @connect gis.surryinfo.net
  529. // @connect gis.talbotdes.org
  530. // @connect gis.tazewell.com
  531. // @connect gis.texoma.cog.tx.us
  532. // @connect gis.thecolonytx.gov
  533. // @connect gis.thomsonreuters.com
  534. // @connect gis.transportation.wv.gov
  535. // @connect gis.transylvaniacounty.org
  536. // @connect gis.traviscountytx.gov
  537. // @connect gis.tularecounty.ca.gov
  538. // @connect gis.ucdavis.edu
  539. // @connect gis.ulstercountyny.gov
  540. // @connect gis.vernon-ct.gov
  541. // @connect gis.victorvilleca.gov
  542. // @connect gis.warrensburg-mo.com
  543. // @connect gis.washingtoncountyny.gov
  544. // @connect gis.watertownwi.gov
  545. // @connect gis.waukesha-wi.gov
  546. // @connect gis.waukeshacounty.gov
  547. // @connect gis.weatherfordtx.gov
  548. // @connect gis.westmorelandcountypa.gov
  549. // @connect gis.westplains.net
  550. // @connect gis.whatcomcounty.us
  551. // @connect gis.whitfieldcountyga.com
  552. // @connect gis.wilco.org
  553. // @connect gis.wilkescounty.net
  554. // @connect gis.willcountyillinois.com
  555. // @connect gis.wilson-co.com
  556. // @connect gis.wilsonnc.org
  557. // @connect gis.wiu.edu
  558. // @connect gis.woodcountywi.gov
  559. // @connect gis.worldviewsolutions.com
  560. // @connect gis.wyo.gov
  561. // @connect gis.yadkincountync.gov
  562. // @connect gis.yanceycountync.org
  563. // @connect gis.yavapaiaz.gov
  564. // @connect gis.yellowstonecountymt.gov
  565. // @connect gis.yolocounty.gov
  566. // @connect gis.yolocounty.org
  567. // @connect gis.yuba.org
  568. // @connect gis1.acimap.us
  569. // @connect gis1.georgetowncountysc.org
  570. // @connect gis1.hamiltoncounty.in.gov
  571. // @connect gis11.cama.io
  572. // @connect gis11.services.ncdot.gov
  573. // @connect gis12.cookcountyil.gov
  574. // @connect gis2.arlingtontx.gov
  575. // @connect gis2.co.dakota.mn.us
  576. // @connect gis2.co.marathon.wi.us
  577. // @connect gis2.co.ozaukee.wi.us
  578. // @connect gis2.erie.gov
  579. // @connect gis2.gworks.com
  580. // @connect gis2.idaho.gov
  581. // @connect gis2.lawrenceks.org
  582. // @connect gis2.orangeburgcounty.org
  583. // @connect gis2.sandyspringsga.gov
  584. // @connect gis2.totaland.com
  585. // @connect gis21svweb.lincolnparish.org
  586. // @connect gis3.cdmsmithgis.com
  587. // @connect gis3.cmpdd.org
  588. // @connect gis3.gwinnettcounty.com
  589. // @connect gis3.gworks.com
  590. // @connect gis3.montgomerycountymd.gov
  591. // @connect gis3.richmondnc.com
  592. // @connect gis4.montgomerycountymd.gov
  593. // @connect gisago-qa.mcgi.state.mi.us
  594. // @connect gisago.mcgi.state.mi.us
  595. // @connect gisapp.adcogov.org
  596. // @connect gisapp.mahoningcountyoh.gov
  597. // @connect gisapps.cityofchicago.org
  598. // @connect gisapps.rileycountyks.gov
  599. // @connect gisapps.wicomicocounty.org
  600. // @connect gisapps1.mapoakland.com
  601. // @connect gisarcweb.jeffersoncountywv.org
  602. // @connect gisccapps.charlestoncounty.org
  603. // @connect gisdata.alleghenycounty.us
  604. // @connect gisdata.dot.ca.gov
  605. // @connect gisdata.in.gov
  606. // @connect gisdata.jeffersoncountyoh.com
  607. // @connect gisdata.kingcounty.gov
  608. // @connect gisdata.pandai.com
  609. // @connect gisdata.pima.gov
  610. // @connect gisdata.seattle.gov
  611. // @connect gisdemo1.cdmsmith.com
  612. // @connect gisdemo2.cdmsmith.com
  613. // @connect gisentapp01.highpointnc.gov
  614. // @connect gisext.lincoln.ne.gov
  615. // @connect gisext.saskatoon.ca
  616. // @connect gisext2.cnv.org
  617. // @connect gishost.cdmsmithgis.com
  618. // @connect gisinfo.co.portage.wi.gov
  619. // @connect gisinfo.co.walworth.wi.us
  620. // @connect gisinfo.lawrencevillega.org
  621. // @connect gismap.augustaga.gov
  622. // @connect gismap.cityofboise.org
  623. // @connect gismap.co.juneau.wi.us
  624. // @connect gismap.co.marshall.mn.us
  625. // @connect gismap.co.norman.mn.us
  626. // @connect gismap.co.red-lake.mn.us
  627. // @connect gismapping.stafford.va.us
  628. // @connect gismaps.cityofboise.org
  629. // @connect gismaps.cityofgreer.org
  630. // @connect gismaps.co.cerro-gordo.ia.us
  631. // @connect gismaps.columbiapa.org
  632. // @connect gismaps.flower-mound.com
  633. // @connect gismaps.fultoncountyga.gov
  634. // @connect gismaps.guelph.ca
  635. // @connect gismaps.hctra.org
  636. // @connect gismaps.kingcounty.gov
  637. // @connect gismaps.redwoodcity.org
  638. // @connect gismaps.sedgwickcounty.org
  639. // @connect gismaps.wichita.gov
  640. // @connect gismapserver.leegov.com
  641. // @connect gismo.spokanecounty.org
  642. // @connect gisonline.greenvillenc.gov
  643. // @connect gisp.co.genesee.ny.us
  644. // @connect gisp.mcgi.state.mi.us
  645. // @connect gisportal.calaverascounty.gov
  646. // @connect gisportal.champaignil.gov
  647. // @connect gisportal.co.madison.il.us
  648. // @connect gisportal.co.warren.oh.us
  649. // @connect gisportal.dorchestercounty.net
  650. // @connect gisportal.dot.ct.gov
  651. // @connect gisportal.fnsb.gov
  652. // @connect gisportal.ircgov.com
  653. // @connect gisportal.ontarioca.gov
  654. // @connect gisportal.stocktonca.gov
  655. // @connect gisportal.stpgov.org
  656. // @connect gisportal.whitehorse.ca
  657. // @connect gispro.porterco.org
  658. // @connect gisprod10.co.fresno.ca.us
  659. // @connect gisprodops.chesco.org
  660. // @connect gispub.cityofaspen.com
  661. // @connect gispub.co.washington.or.us
  662. // @connect gispublic.co.lake.ca.us
  663. // @connect gispw.coloradosprings.gov
  664. // @connect gisrevprxy.seattle.gov
  665. // @connect gisserver.christiancountymo.gov
  666. // @connect gisservice.cityofmesquite.com
  667. // @connect gisservicemt.gov
  668. // @connect gisservices.chathamcountync.gov
  669. // @connect gisservices.chathamnc.org
  670. // @connect gisservices.co.anoka.mn.us
  671. // @connect gisservices.douglasnv.us
  672. // @connect gisservices.its.ny.gov
  673. // @connect gisservices.oakgov.com
  674. // @connect gisservices.surrey.ca
  675. // @connect gisservices2.suffolkcountyny.gov
  676. // @connect gissites4.centrecountypa.gov
  677. // @connect gissvr.watgov.org
  678. // @connect gisweb-18.ci.killeen.tx.us
  679. // @connect gisweb-adapters.bcpa.net
  680. // @connect gisweb.albemarle.org
  681. // @connect gisweb.birminghamal.gov
  682. // @connect gisweb.casscountynd.gov
  683. // @connect gisweb.champaignil.gov
  684. // @connect gisweb.ci.manteca.ca.us
  685. // @connect gisweb.co.aitkin.mn.us
  686. // @connect gisweb.co.mower.mn.us
  687. // @connect gisweb.co.wilkin.mn.us
  688. // @connect gisweb.fdlco.wi.gov
  689. // @connect gisweb.fortbendcountytx.gov
  690. // @connect gisweb.jeffcowa.us
  691. // @connect gisweb.miamidade.gov
  692. // @connect gisweb.pwcva.gov
  693. // @connect gisweb.wycokck.org
  694. // @connect gisweb2014.gordoncounty.org
  695. // @connect giswebservices.countygp.ab.ca
  696. // @connect giswww.westchestergov.com
  697. // @connect git.co.tioga.ny.us
  698. // @connect gmdnags.colliercountyfl.gov
  699. // @connect grant.co.jefferson.id.us
  700. // @connect gweb01.co.olmsted.mn.us
  701. // @connect harpergis.integritygis.com
  702. // @connect haslet.halff.com
  703. // @connect hazards.fema.gov
  704. // @connect hdgis.ingham.org
  705. // @connect heartlandmpo.com
  706. // @connect helenamontanamaps.org
  707. // @connect henrygis.integritygis.com
  708. // @connect hgis.hialeahfl.gov
  709. // @connect holtgis.integritygis.com
  710. // @connect host.cdmsmithgis.com
  711. // @connect hostingdata2.tighebond.com
  712. // @connect hostingdata3.tighebond.com
  713. // @connect huntsvillegis.com
  714. // @connect ifgis.idahofallsidaho.gov
  715. // @connect ihost.tularecounty.ca.gov
  716. // @connect imap.klickitatcounty.org
  717. // @connect ims.districtiii.org
  718. // @connect intervector.leoncountyfl.gov
  719. // @connect iowagis.integritygis.com
  720. // @connect jeffarcgis.jeffersoncountywi.gov
  721. // @connect joplingis.org
  722. // @connect k3gis.com
  723. // @connect kanplan.ksdot.gov
  724. // @connect kcgis.kentoncounty.org
  725. // @connect kenhagis.kenha.co.ke
  726. // @connect kygisserver.ky.gov
  727. // @connect lacledegis.integritygis.com
  728. // @connect lafayettegis.integritygis.com
  729. // @connect landrecords.greencountywi.org
  730. // @connect lawrencegis.integritygis.com
  731. // @connect lcapps.co.lucas.oh.us
  732. // @connect lcmaps.lanecounty.org
  733. // @connect lee-arcgis.leecountync.gov
  734. // @connect lincolngis.integritygis.com
  735. // @connect lio.milwaukeecountywi.gov
  736. // @connect livingstongis.integritygis.com
  737. // @connect location.cabarruscounty.us
  738. // @connect logis.loudoun.gov
  739. // @connect loraincountyauditor.com
  740. // @connect lrs.co.columbia.wi.us
  741. // @connect lucity.sbpg.net
  742. // @connect macongis.integritygis.com
  743. // @connect madison.rexburg.org
  744. // @connect madisongis.cityofalbany.net
  745. // @connect manitowocmaps.info
  746. // @connect map.claycountymn.gov
  747. // @connect map.co.clear-creek.co.us
  748. // @connect map.co.clearwater.mn.us
  749. // @connect map.co.merced.ca.us
  750. // @connect map.co.thurston.wa.us
  751. // @connect map.co.trempealeau.wi.us
  752. // @connect map.coppelltx.gov
  753. // @connect map.eaglecounty.us
  754. // @connect map.haltonhills.ca
  755. // @connect map.newberrycounty.net
  756. // @connect map.opkansas.org
  757. // @connect map.oshawa.ca
  758. // @connect map.pikepass.com
  759. // @connect map.rdn.bc.ca
  760. // @connect map.stclairco.com
  761. // @connect map.sussexcountyde.gov
  762. // @connect map.wyoroad.info
  763. // @connect map11.incog.org
  764. // @connect mapd.kcmo.org
  765. // @connect mapdata.baytown.org
  766. // @connect mapdata.lasvegasnevada.gov
  767. // @connect mapdata.tucsonaz.gov
  768. // @connect mapit.fortworthtexas.gov
  769. // @connect mapit.tarrantcounty.com
  770. // @connect mapitwest.fortworthtexas.gov
  771. // @connect mapping.adamscountypa.gov
  772. // @connect mapping.burlington.ca
  773. // @connect mapping.chilliwack.com
  774. // @connect mapping.kenoshacountywi.gov
  775. // @connect mapping.mitchellcounty.org
  776. // @connect mapping.modot.org
  777. // @connect mappmycity.ca
  778. // @connect maps.adaok.com
  779. // @connect maps.alexandercountync.gov
  780. // @connect maps.alexandriava.gov
  781. // @connect maps.austintexas.gov
  782. // @connect maps.banff.ca
  783. // @connect maps.bannockcounty.us
  784. // @connect maps.bayfieldcounty.wi.gov
  785. // @connect maps.bcad.org
  786. // @connect maps.belmont.gov
  787. // @connect maps.berkeleywv.org
  788. // @connect maps.boonecountyil.org
  789. // @connect maps.bossierparishgis.org
  790. // @connect maps.bouldercounty.org
  791. // @connect maps.brazoriacountytx.gov
  792. // @connect maps.brla.gov
  793. // @connect maps.brookhavenga.gov
  794. // @connect maps.bryantx.gov
  795. // @connect maps.burlesontx.com
  796. // @connect maps.butlercountyauditor.org
  797. // @connect maps.cambridge.ca
  798. // @connect maps.canyonco.org
  799. // @connect maps.capturecama.com
  800. // @connect maps.casperwy.gov
  801. // @connect maps.chautauquacounty.com
  802. // @connect maps.cherokeecounty-nc.gov
  803. // @connect maps.ci.longmont.co.us
  804. // @connect maps.ci.nacogdoches.tx.us
  805. // @connect maps.cityhs.net
  806. // @connect maps.cityofconroe.org
  807. // @connect maps.cityofhenderson.com
  808. // @connect maps.cityofls.net
  809. // @connect maps.cityofmadison.com
  810. // @connect maps.cityofmobile.org
  811. // @connect maps.cityofsherman.com
  812. // @connect maps.cityoftulsa.org
  813. // @connect maps.cityofwaterlooiowa.com
  814. // @connect maps.clarkcountynv.gov
  815. // @connect maps.claycountygov.com
  816. // @connect maps.clermontauditor.org
  817. // @connect maps.clintoncountypa.com
  818. // @connect maps.co.blaine.id.us
  819. // @connect maps.co.ellis.tx.us
  820. // @connect maps.co.forsyth.nc.us
  821. // @connect maps.co.goodhue.mn.us
  822. // @connect maps.co.gov
  823. // @connect maps.co.grayson.tx.us
  824. // @connect maps.co.itasca.mn.us
  825. // @connect maps.co.kendall.il.us
  826. // @connect maps.co.kern.ca.us
  827. // @connect maps.co.lincoln.wi.us
  828. // @connect maps.co.palm-beach.fl.us
  829. // @connect maps.co.polk.or.us
  830. // @connect maps.co.pueblo.co.us
  831. // @connect maps.co.ramsey.mn.us
  832. // @connect maps.co.shawano.wi.us
  833. // @connect maps.co.warren.oh.us
  834. // @connect maps.co.washington.mn.us
  835. // @connect maps.coj.net
  836. // @connect maps.collincountytx.gov
  837. // @connect maps.countyofmerced.com
  838. // @connect maps.crc.ga.gov
  839. // @connect maps.ctmetro.org
  840. // @connect maps.currituckcountync.gov
  841. // @connect maps.cvrd.ca
  842. // @connect maps.dancgis.org
  843. // @connect maps.dcad.org
  844. // @connect maps.delco-gis.org
  845. // @connect maps.deltacountyco.gov
  846. // @connect maps.deschutes.org
  847. // @connect maps.desotocountyms.gov
  848. // @connect maps.dmgov.org
  849. // @connect maps.dot.nh.gov
  850. // @connect maps.dotd.la.gov
  851. // @connect maps.douglascountyga.gov
  852. // @connect maps.douglascountywa.net
  853. // @connect maps.dsm.city
  854. // @connect maps.durham.ca
  855. // @connect maps.elbertcounty-co.gov
  856. // @connect maps.escpa.org
  857. // @connect maps.etcog.org
  858. // @connect maps.evansvillegis.com
  859. // @connect maps.fayetteville-ar.gov
  860. // @connect maps.fishers.in.us
  861. // @connect maps.flathead.mt.gov
  862. // @connect maps.floridadisaster.org
  863. // @connect maps.frederickcountymd.gov
  864. // @connect maps.fredericksburgva.gov
  865. // @connect maps.garfield-county.com
  866. // @connect maps.garlandtx.gov
  867. // @connect maps.gov.bc.ca
  868. // @connect maps.grcity.us
  869. // @connect maps.groton-ct.gov
  870. // @connect maps.grundyco.org
  871. // @connect maps.haldimandcounty.on.ca
  872. // @connect maps.hayward-ca.gov
  873. // @connect maps.haywoodnc.net
  874. // @connect maps.highlandvillage.org
  875. // @connect maps.hokecounty.org
  876. // @connect maps.huerfano.us
  877. // @connect maps.huntsvilleal.gov
  878. // @connect maps.iredellcountync.gov
  879. // @connect maps.itos.uga.edu
  880. // @connect maps.jocogov.org
  881. // @connect maps.kamloops.ca
  882. // @connect maps.kytc.ky.gov
  883. // @connect maps.lacity.org
  884. // @connect maps.lagrange-ga.org
  885. // @connect maps.lakecountyil.gov
  886. // @connect maps.laramiecounty.com
  887. // @connect maps.lcwy.org
  888. // @connect maps.lebanontn.org
  889. // @connect maps.lex-co.com
  890. // @connect maps.lexingtonky.gov
  891. // @connect maps.libertymo.gov
  892. // @connect maps.lincolncountysd.org
  893. // @connect maps.linkgis.org
  894. // @connect maps.london.ca
  895. // @connect maps.matsugov.us
  896. // @connect maps.mckinneytexas.org
  897. // @connect maps.meshekgis.com
  898. // @connect maps.miamigov.com
  899. // @connect maps.midlandtexas.gov
  900. // @connect maps.monroecounty.gov
  901. // @connect maps.muskegoncountygis.com
  902. // @connect maps.nashville.gov
  903. // @connect maps.ncpafl.com
  904. // @connect maps.nevadacountyca.gov
  905. // @connect maps.nj.gov
  906. // @connect maps.normanok.gov
  907. // @connect maps.northaugustasc.gov
  908. // @connect maps.ocgov.net
  909. // @connect maps.opkansas.org
  910. // @connect maps.orcity.org
  911. // @connect maps.ottawa.ca
  912. // @connect maps.palmcoastgov.com
  913. // @connect maps.parkco.us
  914. // @connect maps.phoenix.gov
  915. // @connect maps.pitkincounty.com
  916. // @connect maps.planogis.org
  917. // @connect maps.pottercountypa.net
  918. // @connect maps.prcity.com
  919. // @connect maps.raleighnc.gov
  920. // @connect maps.richlandcountyoh.us
  921. // @connect maps.rutherfordcountytn.gov
  922. // @connect maps.santa-clarita.com
  923. // @connect maps.santabarbaraca.gov
  924. // @connect maps.sbcounty.gov
  925. // @connect maps.sccmo.org
  926. // @connect maps.semogis.com
  927. // @connect maps.sfdpw.org
  928. // @connect maps.sgcityutah.gov
  929. // @connect maps.shelbyal.com
  930. // @connect maps.slocity.org
  931. // @connect maps.spartanburgcounty.org
  932. // @connect maps.springfieldmo.gov
  933. // @connect maps.steamboatsprings.net
  934. // @connect maps.stlouisco.com
  935. // @connect maps.swaincountync.gov
  936. // @connect maps.tippecanoe.in.gov
  937. // @connect maps.townofcary.org
  938. // @connect maps.udot.utah.gov
  939. // @connect maps.vancouver.ca
  940. // @connect maps.vcgi.vermont.gov
  941. // @connect maps.ventura.org
  942. // @connect maps.victoria.ca
  943. // @connect maps.victoriatx.org
  944. // @connect maps.vilascountywi.gov
  945. // @connect maps.vtrans.vermont.gov
  946. // @connect maps.wake.gov
  947. // @connect maps.washco-md.net
  948. // @connect maps.washcowisco.gov
  949. // @connect maps.whiterockcity.ca
  950. // @connect maps1.brampton.ca
  951. // @connect maps1.eriecounty.oh.gov
  952. // @connect maps1.larimer.org
  953. // @connect maps11.eriecounty.oh.gov
  954. // @connect maps2.bgadd.org
  955. // @connect maps2.cattco.org
  956. // @connect maps2.ci.euless.tx.us
  957. // @connect maps2.columbus.gov
  958. // @connect maps2.dcgis.dc.gov
  959. // @connect maps2.san-marcos.net
  960. // @connect maps2.timmons.com
  961. // @connect maps2.vcgov.org
  962. // @connect maps6.stlouis-mo.gov
  963. // @connect maps7.eriecounty.oh.gov
  964. // @connect maps8.eriecounty.oh.gov
  965. // @connect mapsdev.hamiltontn.gov
  966. // @connect mapserv.cityofloveland.org
  967. // @connect mapserv.mesquitenv.gov
  968. // @connect mapservice.nmstatelands.org
  969. // @connect mapservices.crd.bc.ca
  970. // @connect mapservices.gis.saccounty.net
  971. // @connect mapservices.gov.yk.ca
  972. // @connect mapservices.nps.gov
  973. // @connect mapservices.pasda.psu.edu
  974. // @connect mapservices.santacruzcountyaz.gov
  975. // @connect mapservices.sccgov.org
  976. // @connect mapservices.weather.noaa.gov
  977. // @connect mapservices1.jeffco.us
  978. // @connect mapservices2.jeffco.us
  979. // @connect mariesgis.integritygis.com
  980. // @connect mariongis.integritygis.com
  981. // @connect mcdonaldgis.integritygis.com
  982. // @connect mcgis.mesacounty.us
  983. // @connect mcgis.mohave.gov
  984. // @connect mcgis4.monroecounty-fl.gov
  985. // @connect mcmap.montrosecounty.net
  986. // @connect mcogis.co.marion.oh.us
  987. // @connect millergis.integritygis.com
  988. // @connect mms.hursttx.gov
  989. // @connect mndotgis.dot.state.mn.us
  990. // @connect moberlygis.integritygis.com
  991. // @connect mobile.alamedaca.gov
  992. // @connect moniteaugis.integritygis.com
  993. // @connect morgangis.integritygis.com
  994. // @connect msdisweb.missouri.edu
  995. // @connect mycity2.houstontx.gov
  996. // @connect navigator.state.or.us
  997. // @connect newtongis.integritygis.com
  998. // @connect nhgeodata.unh.edu
  999. // @connect nobgis.cityofnoblesville.org
  1000. // @connect northlake.halff.com
  1001. // @connect nsgiwa.novascotia.ca
  1002. // @connect nspdcwebsrv.csuchico.edu
  1003. // @connect oak.co.lake-of-the-woods.mn.us
  1004. // @connect oc17maps.co.oconto.wi.us
  1005. // @connect ocgis4.ocfl.net
  1006. // @connect oncorng.co.ontario.ny.us
  1007. // @connect opengis.regina.ca
  1008. // @connect operationserver.ci.henderson.nc.us
  1009. // @connect orfmaps.norfolk.gov
  1010. // @connect osagegis.integritygis.com
  1011. // @connect pagis.org
  1012. // @connect pamap.putnam-fl.gov
  1013. // @connect parcelmap.ashtabulacounty.us
  1014. // @connect parcels.rsdigital.com
  1015. // @connect parcelviewer.geodecisions.com
  1016. // @connect pascogis.pascocountyfl.net
  1017. // @connect pgis.plantation.org
  1018. // @connect phelpsgis.integritygis.com
  1019. // @connect polaris2.mecklenburgcountync.gov
  1020. // @connect polkgis.integritygis.com
  1021. // @connect portal.carolinabeach.org
  1022. // @connect portal.carson.org
  1023. // @connect portal.henrico.gov
  1024. // @connect portal.niagarafalls.ca
  1025. // @connect programs.iowadnr.gov
  1026. // @connect propaccess.wadtx.com
  1027. // @connect propertyviewer.andersoncountysc.org
  1028. // @connect proxy2.roktech.net
  1029. // @connect psportal.harrisoncountywv.com
  1030. // @connect pubgis.ci.lubbock.tx.us
  1031. // @connect public.co.wasco.or.us
  1032. // @connect public1.co.waupaca.wi.us
  1033. // @connect publicmap01.co.st-clair.il.us
  1034. // @connect publicmaps.txkusa.org
  1035. // @connect pulaskigis.integritygis.com
  1036. // @connect putnamcountygis.com
  1037. // @connect pwmaps.cityofloveland.org
  1038. // @connect pwmaps.reno.gov
  1039. // @connect rallsgis.integritygis.com
  1040. // @connect raygis.integritygis.com
  1041. // @connect rc-arcgis01.co.rice.mn.us
  1042. // @connect rdsgis.nctgis.nct911.org
  1043. // @connect renogis3.renogov.org
  1044. // @connect roads.udot.utah.gov
  1045. // @connect rockgis.co.rock.wi.us
  1046. // @connect rockgis.rockfordil.gov
  1047. // @connect romefloyd.agdmaps.com
  1048. // @connect rptsgisweb.oswegocounty.com
  1049. // @connect salinegis.integritygis.com
  1050. // @connect saludacountysc.net
  1051. // @connect sccgis.santacruzcountyca.gov
  1052. // @connect scgis.summitoh.net
  1053. // @connect scgisa.starkcountyohio.gov
  1054. // @connect sdgis.sd.gov
  1055. // @connect secure.boonecountygis.com
  1056. // @connect sedaliagis.integritygis.com
  1057. // @connect see-eldorado.edcgov.us
  1058. // @connect server.boundarycountyid.org
  1059. // @connect server1.mapxpress.net
  1060. // @connect server2.mapxpress.net
  1061. // @connect services.aadnc-aandc.gc.ca
  1062. // @connect services.arcgis.com
  1063. // @connect services.gis.ca.gov
  1064. // @connect services.gisqatar.org.qa
  1065. // @connect services.mh-gis.com
  1066. // @connect services.nconemap.gov
  1067. // @connect services.sagis.org
  1068. // @connect services.wvgis.wvu.edu
  1069. // @connect services1.arcgis.com
  1070. // @connect services2.arcgis.com
  1071. // @connect services2.integritygis.com
  1072. // @connect services3.arcgis.com
  1073. // @connect services5.arcgis.com
  1074. // @connect services6.arcgis.com
  1075. // @connect services7.arcgis.com
  1076. // @connect services8.arcgis.com
  1077. // @connect services9.arcgis.com
  1078. // @connect showlowmaps.com
  1079. // @connect skyview.hornershifrin.com
  1080. // @connect slcgis.stlucieco.gov
  1081. // @connect smgis.sanmarcostx.gov
  1082. // @connect smithvillegis.integritygis.com
  1083. // @connect smpesri.scdot.org
  1084. // @connect socogis.sonomacounty.ca.gov
  1085. // @connect spatial.gishost.com
  1086. // @connect spatial.jacksoncountyor.gov
  1087. // @connect spatialags.vhb.com
  1088. // @connect stclairgis.integritygis.com
  1089. // @connect stmgis.stmarysmd.com
  1090. // @connect stokescountygis.com
  1091. // @connect stonegis.integritygis.com
  1092. // @connect svr4.sumtercountysc.org
  1093. // @connect tcgisws.tooeleco.gov
  1094. // @connect tcweb.co.teller.co.us
  1095. // @connect tfportal.tfid.org
  1096. // @connect tharcgis2.thewoodlands-tx.gov
  1097. // @connect tigerweb.geo.census.gov
  1098. // @connect tiogagis.tiogacountypa.us
  1099. // @connect tnmap.tn.gov
  1100. // @connect tpwd.texas.gov
  1101. // @connect tsc-gis-ags101a.schneidercorp.com
  1102. // @connect twu.newedgeservices.com
  1103. // @connect utility.arcgis.com
  1104. // @connect vernongis.integritygis.com
  1105. // @connect vginmaps.vdem.virginia.gov
  1106. // @connect vtransmap01.aot.state.vt.us
  1107. // @connect wallawallagis.com
  1108. // @connect warrengis.integritygis.com
  1109. // @connect wcg-gisweb.co.worcester.md.us
  1110. // @connect wcgis3.co.winnebago.wi.us
  1111. // @connect wcgisweb.washoecounty.us
  1112. // @connect wcoh.geopowered.com
  1113. // @connect web.binghamid.gov
  1114. // @connect web2.co.ottertail.mn.us
  1115. // @connect web2.kcsgis.com
  1116. // @connect web3.kcsgis.com
  1117. // @connect web4.kcsgis.com
  1118. // @connect web5.kcsgis.com
  1119. // @connect webadaptor.glynncounty-ga.gov
  1120. // @connect webgis.bedfordcountyva.gov
  1121. // @connect webgis.co.davidson.nc.us
  1122. // @connect webgis.durhamnc.gov
  1123. // @connect webgis.lafayetteassessor.com
  1124. // @connect webgis.providenceri.gov
  1125. // @connect webgis.waterburyct.org
  1126. // @connect webgis.yorbalindaca.gov
  1127. // @connect webmap.co.jackson.ms.us
  1128. // @connect webmap.jeffparish.net
  1129. // @connect webmap.trueautomation.com
  1130. // @connect webmaps.elkgrovecity.org
  1131. // @connect webmaps.sjcounty.net
  1132. // @connect webportal.co.marquette.wi.us
  1133. // @connect websrv31.clallamcountywa.gov
  1134. // @connect webstergis.integritygis.com
  1135. // @connect wfs.ksdot.org
  1136. // @connect wfs.schneidercorp.com
  1137. // @connect ws.lioservices.lrc.gov.on.ca
  1138. // @connect wvsams.mapwv.org
  1139. // @connect ww1.bucoks.com
  1140. // @connect ww8.yorkmaps.ca
  1141. // @connect www.1stdistrict.org
  1142. // @connect www.adacountyassessor.org
  1143. // @connect www.adamscountyarcserver.com
  1144. // @connect www.ancgis.com
  1145. // @connect www.apps.geomatics.gov.nt.ca
  1146. // @connect www.bartowgis.org
  1147. // @connect www.bcgis.com
  1148. // @connect www.bcpao.us
  1149. // @connect www.centralilmaps.com
  1150. // @connect www.cmbgis.com
  1151. // @connect www.colesco.illinois.gov
  1152. // @connect www.ctgismaps2.ct.gov
  1153. // @connect www.denvergov.org
  1154. // @connect www.dmcwebgis.com
  1155. // @connect www.efsedge.com
  1156. // @connect www.finneycountygis.com
  1157. // @connect www.franklinmo.net
  1158. // @connect www.gcgis.org
  1159. // @connect www.gfgis.com
  1160. // @connect www.gis.hctx.net
  1161. // @connect www.gis.sjcfl.us
  1162. // @connect www.gismidwest.com
  1163. // @connect www.gisonline.ms.gov
  1164. // @connect www.greenwoodsc.gov
  1165. // @connect www.hernandocountygis-florida.us
  1166. // @connect www.hogarcmaps.org
  1167. // @connect www.horrycountysc.gov
  1168. // @connect www.landmarkgeospatial.com
  1169. // @connect www.laurenscountygis.org
  1170. // @connect www.mcgisweb.org
  1171. // @connect www.mchenrycountygis.org
  1172. // @connect www.midmogis.org
  1173. // @connect www.monroegis.org
  1174. // @connect www.mymanatee.org
  1175. // @connect www.ocgis.com
  1176. // @connect www.portlandmaps.com
  1177. // @connect www.rdcogis.com
  1178. // @connect www.sciotocountyengineer.org
  1179. // @connect www.semogis.com
  1180. // @connect www.sgrcmaps.com
  1181. // @connect www.sjmap.org
  1182. // @connect www.smithcountymapsite.org
  1183. // @connect www.tgisites.com
  1184. // @connect www.valorgis.com
  1185. // @connect www.waynecounty.com
  1186. // @connect www.webgis.net
  1187. // @connect www.yamhillcountygis.com
  1188. // @connect www1.cityofwebster.com
  1189. // @connect www2.ci.lancaster.oh.us
  1190. // @connect www2.pottcounty.org
  1191. // @connect www3.multco.us
  1192. // @connect www7.co.union.oh.us
  1193. // @connect xara1-4.cityofpetaluma.net
  1194. // @connect xmaps.indy.gov
  1195. // ==/UserScript==
  1196.  
  1197. /* global WazeWrap, _, turf, ESTreeProcessor, bootstrap, OpenLayers, wmeGisLBBOX */
  1198.  
  1199. (async function main() {
  1200. 'use strict';
  1201.  
  1202. // **************************************************************************************************************
  1203. // IMPORTANT: Update this when releasing a new version of script
  1204. // **************************************************************************************************************
  1205. const SHOW_UPDATE_MESSAGE = true;
  1206. const SCRIPT_VERSION_CHANGES = [
  1207. 'Minor update: 2025.08.10.00',
  1208. 'Layer definitions now load via Google Sheets Visualization API (/gviz endpoint).',
  1209. 'No more API key or referrer restrictions so loading is more reliable in all browsers.',
  1210. 'Fixes issues caused by privacy extensions/content blockers (like AdGuard).',
  1211. ];
  1212.  
  1213. const GF_URL = 'https://greasyfork.org/scripts/369632-wme-gis-layers';
  1214. // Used in tooltips to tell people who to report issues to. Update if a new author takes ownership of this script.
  1215. const SCRIPT_AUTHOR = 'MapOMatic / JS55CT';
  1216. const REQUEST_FORM_URL = 'https://docs.google.com/forms/d/e/1FAIpQLSevPQLz2ohu_LTge9gJ9Nv6PURmCmaSSjq0ayOJpGdRr2xI0g/viewform?usp=pp_url&entry.2116052852={username}';
  1217. const DEFAULT_LAYER_NAME = 'GIS Layers - Default';
  1218. const ROAD_LAYER_NAME = 'GIS Layers - Roads';
  1219.  
  1220. /**
  1221. * @typedef {Object} StyleDefinition
  1222. * @property {string} [fillColor]
  1223. * @property {number} [pointRadius]
  1224. * @property {string} [label]
  1225. * @property {number} [fillOpacity]
  1226. * @property {string} [strokeColor]
  1227. * @property {number} [strokeOpacity]
  1228. * @property {number} [strokeWidth]
  1229. * @property {string} [fontColor]
  1230. * @property {number|string} [fontSize]
  1231. * @property {string} [labelOutlineColor]
  1232. * @property {number|string} [labelOutlineWidth]
  1233. * @property {string} [fontWeight]
  1234. * @property {number} [labelYOffset]
  1235. * @property {string} [labelAlign]
  1236. * @property {string} [pathLabel]
  1237. * @property {boolean} [labelSelect]
  1238. * @property {string|number} [pathLabelYOffset]
  1239. * @property {string|number} [pathLabelCurve]
  1240. * @property {string|number} [pathLabelReadable]
  1241. * @property {boolean} [stroke]
  1242. */
  1243. /** @type {StyleDefinition} */
  1244. const DEFAULT_STYLE = {
  1245. fillColor: '#000',
  1246. pointRadius: 4,
  1247. label: '${getLabel}',
  1248. fillOpacity: 0.95,
  1249. strokeColor: '#ffa500',
  1250. strokeOpacity: 0.95,
  1251. strokeWidth: 1.5,
  1252. fontColor: '#ffc520',
  1253. fontSize: '13',
  1254. labelOutlineColor: 'black',
  1255. labelOutlineWidth: 3,
  1256. };
  1257.  
  1258. /** @type {Object.<string, StyleDefinition>} */
  1259. const LAYER_STYLES = {
  1260. cities: {
  1261. fillOpacity: 0.3,
  1262. fillColor: '#f65',
  1263. strokeColor: '#f65',
  1264. fontColor: '#f62',
  1265. },
  1266. forests_parks: {
  1267. fillOpacity: 0.4,
  1268. fillColor: '#585',
  1269. strokeColor: '#484',
  1270. fontColor: '#8b8',
  1271. },
  1272. milemarkers: {
  1273. strokeColor: '#fff',
  1274. fontColor: '#fff',
  1275. fontWeight: 'bold',
  1276. fillOpacity: 0,
  1277. labelYOffset: 10,
  1278. pointRadius: 2,
  1279. fontSize: 12,
  1280. },
  1281. parcels: {
  1282. fillOpacity: 0,
  1283. fillColor: '#ffa500',
  1284. },
  1285. points: {
  1286. strokeColor: '#000',
  1287. fontColor: '#0ff',
  1288. fillColor: '#0ff',
  1289. labelYOffset: -10,
  1290. labelAlign: 'ct',
  1291. },
  1292. post_offices: {
  1293. strokeColor: '#000',
  1294. fontColor: '#f84',
  1295. fillColor: '#f84',
  1296. fontWeight: 'bold',
  1297. labelYOffset: -10,
  1298. labelAlign: 'ct',
  1299. },
  1300. state_parcels: {
  1301. fillOpacity: 0,
  1302. strokeColor: '#e62',
  1303. fillColor: '#e62',
  1304. fontColor: '#e73',
  1305. },
  1306. state_points: {
  1307. strokeColor: '#000',
  1308. fontColor: '#3cf',
  1309. fillColor: '#3cf',
  1310. labelYOffset: -10,
  1311. labelAlign: 'ct',
  1312. },
  1313. road_labels: {
  1314. strokeOpacity: 0,
  1315. fillOpacity: 0,
  1316. fontColor: '#faf',
  1317. },
  1318. structures: {
  1319. fillOpacity: 0,
  1320. strokeColor: '#f7f',
  1321. fontColor: '#f7f',
  1322. },
  1323. };
  1324. /** @type {StyleDefinition} */
  1325. let ROAD_STYLE = {
  1326. pointRadius: 12,
  1327. fillColor: '#369',
  1328. pathLabel: '${getLabel}',
  1329. label: '',
  1330. fontColor: '#faf',
  1331. labelSelect: true,
  1332. pathLabelYOffset: '${getOffset}',
  1333. pathLabelCurve: '${getSmooth}',
  1334. pathLabelReadable: '${getReadable}',
  1335. labelAlign: '${getAlign}',
  1336. labelOutlineWidth: 3,
  1337. labelOutlineColor: '#000',
  1338. strokeWidth: 3,
  1339. stroke: true,
  1340. strokeColor: '#f0f',
  1341. strokeOpacity: 0.4,
  1342. fontWeight: 'bold',
  1343. fontSize: 11,
  1344. };
  1345.  
  1346. /**
  1347. * Common regexes used for label cleansing/transformation.
  1348. * @type {Object.<string, RegExp>}
  1349. */
  1350. const _regexReplace = {
  1351. // Strip leading zeros or blank full label for any label starting with a non-digit or
  1352. // is a Zero Address, use with '' as replace.
  1353. r0: /^(0+(\s.*)?|\D.*)/,
  1354. // Strip Everything After Street Type to end of the string by use $1 and $2 capture
  1355. // groups, use with replace '$1$2'
  1356. // eslint-disable-next-line max-len
  1357. r1: /^(.* )(Ave(nue)?|Dr(ive)?|St(reet)?|C(our)?t|Cir(cle)?|Blvd|Boulevard|Pl(ace)?|Ln|Lane|Fwy|Freeway|R(oa)?d|Ter(r|race)?|Tr(ai)?l|Way|Rte \d+|Route \d+)\b.*/gi,
  1358. // Strip SPACE 5 Digits from end of string, use with replace ''
  1359. r2: /\s\d{5}$/,
  1360. // Strip Everything after a "~", ",", ";" to the end of the string, use with replace ''
  1361. r3: /(~|,|;|\s?\r\n).*$/,
  1362. // Move the digits after the last space to before the rest of the string using, use with
  1363. // replace '$2 $1'
  1364. r4: /^(.*)\s(\d+).*/,
  1365. // Insert newline between digits (including "-") and everything after the digits,
  1366. // except(and before) a ",", use with replace '$1\n$2'
  1367. r5: /^([-\d]+)\s+([^,]+).*/,
  1368. // Insert newline between digits and everything after the digits, use with
  1369. // replace '$1\n$2'
  1370. r6: /^(\d+)\s+(.*)/,
  1371. };
  1372.  
  1373. /**
  1374. * @typedef {Object} GisLayer
  1375. * @property {string} id - Unique identifier for the GIS layer.
  1376. * @property {number} enabled - 1 if the layer is enabled, 0 otherwise.
  1377. * @property {string} name - Human-readable name of the layer.
  1378. * @property {string} country - Country ISO code associated with the layer (uppercased).
  1379. * @property {string} subL1 - Subdivision level 1 code (uppercased).
  1380. * @property {string[]} [subL2] - Optional array of subdivision level 2 names (parsed from comma-separated string).
  1381. * @property {string} url - Service URL for the GIS layer.
  1382. * @property {string} [where] - Optional SQL/query filter string.
  1383. * @property {string[]} [labelFields] - Array of label field names (parsed, or [''] if missing).
  1384. * @property {string} [processLabel] - Optional label processing JavaScript code (as a string).
  1385. * @property {boolean} [labelProcessingError] - True if an error occurred while compiling processLabel.
  1386. * @property {Object|string} [style] - Style object (parsed from JSON) or "roads" for road layers.
  1387. * @property {boolean} [isRoadLayer] - True if the style is set to "roads".
  1388. * @property {number|null} [visibleAtZoom] - Minimum zoom level at which the layer is visible (or null).
  1389. * @property {number|null} [labelsVisibleAtZoom] - Minimum zoom level at which labels are visible (or null).
  1390. * @property {string} [restrictTo] - Restriction rules for this layer (parsed for "notAllowed").
  1391. * @property {boolean} [notAllowed] - True if restrictions disallow the current user (based on restrictTo).
  1392. * @property {string} [oneTimeAlert] - One-time alert message for this layer.
  1393. * @property {string} [platform] - Detected service platform (e.g., "ArcGIS", "SocrataV2", "SocrataV3", "Other").
  1394. * @property {string} countrySubL1 - Computed country and SubL1 code combined (e.g., "USA-CALIFORNIA").
  1395. */
  1396. /** @type {GisLayer[]} */
  1397. let _gisLayers = [];
  1398.  
  1399. /**
  1400. * Information about a single country in results.
  1401. * @typedef {object} WhatsInViewCountry
  1402. * @property {string} ISO_ALPHA2
  1403. * @property {string} ISO_ALPHA3
  1404. * @property {number} Sub_level
  1405. * @property {string} [source]
  1406. * @property {Object<string, Object>|Object} subL1
  1407. * Intersecting subdivisions (states/counties/etc). Structure depends on country and precision.
  1408. */
  1409.  
  1410. /**
  1411. * Main return type for whatsInView.
  1412. * - Keys are country names, values are country info objects.
  1413. * @typedef {Object.<string, WhatsInViewCountry>} WhatsInViewResult
  1414. */
  1415.  
  1416. /** @type {WhatsInViewResult} */
  1417. let _whatsInView = {};
  1418.  
  1419. /** @type {Set<string>} Set of ISO_ALPHA3 country codes already loaded */
  1420. const alreadyLoadedCountries = new Set();
  1421.  
  1422. /** @type {Set<string>} Set of subdivision (subL1_id) codes already loaded */
  1423. const alreadyLoadedSubL1 = new Set();
  1424.  
  1425. /**
  1426. * @typedef {object} ViewportBBox
  1427. * @property {number} minLon
  1428. * @property {number} minLat
  1429. * @property {number} maxLon
  1430. * @property {number} maxLat
  1431. */
  1432. /**
  1433. * @typedef {object} wmeGisLBBOX
  1434. * @property {(url: string) => Promise<object>} fetchJsonWithCache
  1435. * @property {(viewportBbox: ViewportBBox) => Promise<Array<{ISO_ALPHA2:string, ISO_ALPHA3:string, name:string, Sub_level:number, source:string}>>} getIntersectingCountries
  1436. * @property {() => Promise<Object>} getCountriesAndSubsJson
  1437. * @property {(intersectingCountries: Object) => void} cleanIntersectingData
  1438. * @property {(countyCode: string, subCode: string, subSubCode: string, viewportBbox: ViewportBBox, returnGeoJson?: boolean) => Promise<boolean|Object>} fetchAndCheckGeoJsonIntersection
  1439. * @property {(viewportBbox: ViewportBBox, highPrecision?: boolean, returnGeoJson?: boolean) => Promise<Object>} getIntersectingStatesAndCounties
  1440. * @property {(countryObj: Object, viewportBbox: ViewportBBox) => Promise<Object>} getIntersectingSubdivisions
  1441. * @property {(viewportBbox: ViewportBBox, highPrecision?: boolean, returnGeoJson?: boolean) => Promise<Object>} whatsInView
  1442. */
  1443. /** @type {wmeGisLBBOX} */
  1444. const WmeGisLBBOX = new wmeGisLBBOX(); // Create and reuse this instance as wmeGisLBBOX uses an instance-level cache (i.e., this.cache)
  1445.  
  1446. /**
  1447. * Maps a string key (`countryId-countryId` or `countryId-subdivisionId`) to a full name string.
  1448. * Example keys: "US-US", "US-CA", etc.
  1449. * Example values: "US - United States", "US - California", etc.
  1450. * @type {Object.<string, string>}
  1451. */
  1452. let countrySubdivisionMapping = {};
  1453.  
  1454. /**
  1455. * Asynchronously builds a mapping from 'countryId-subdivisionId' identifiers to their respective names.
  1456. *
  1457. * Retrieves country and subdivision data using WmeGisLBBOX.getCountriesAndSubsJson(),
  1458. * iterates over the data, and constructs an object where each key is a combination of
  1459. * country and subdivision IDs and each value is the corresponding name ("US - California").
  1460. *
  1461. * @returns {Promise<Object.<string, string>>} Resolves to the mapping object.
  1462. */
  1463. async function buildCountrySubdivisionMapping() {
  1464. const countriesAndSubs = await WmeGisLBBOX.getCountriesAndSubsJson();
  1465. for (const [countryId, countryData] of Object.entries(countriesAndSubs)) {
  1466. const countryName = countryData.name;
  1467.  
  1468. // Add country itself with key 'countryId-countryId'
  1469. countrySubdivisionMapping[`${countryId}`] = countryName;
  1470. countrySubdivisionMapping[`${countryId}-${countryId}`] = `${countryId} - ${countryName}`;
  1471.  
  1472. if (countryData.subL1) {
  1473. for (const [subId, subData] of Object.entries(countryData.subL1)) {
  1474. const subName = subData.name;
  1475. const key = `${countryId}-${subId}`;
  1476. const value = `${countryId} - ${subName}`;
  1477. countrySubdivisionMapping[key] = value;
  1478. }
  1479. }
  1480. }
  1481. return countrySubdivisionMapping;
  1482. }
  1483.  
  1484. /**
  1485. * Helper for mapping between country-subdivision keys and their full names.
  1486. */
  1487. const NameMapper = {
  1488. /**
  1489. * Converts a full name ("US - California") to its key ("US-CA").
  1490. * @param {string} fullName - Full name to convert.
  1491. * @returns {string|undefined} Matching key, or undefined if not found.
  1492. */
  1493. toKey(fullName) {
  1494. return Object.entries(countrySubdivisionMapping).find(([, value]) => value === fullName)?.[0];
  1495. },
  1496.  
  1497. /**
  1498. * Converts a key ("US-CA") to its full name ("US - California").
  1499. * @param {string} key
  1500. * @returns {string} The corresponding full name or undefined.
  1501. */
  1502. toFullName(key) {
  1503. return countrySubdivisionMapping[key];
  1504. },
  1505.  
  1506. /**
  1507. * Returns all full names in the mapping.
  1508. * @returns {Array<string>} Array of all full names.
  1509. */
  1510. toFullNameArray() {
  1511. return Object.values(countrySubdivisionMapping);
  1512. },
  1513.  
  1514. /**
  1515. * Returns all keys in the mapping.
  1516. * @returns {Array<string>} Array of all keys.
  1517. */
  1518. toKeyArray() {
  1519. return Object.keys(countrySubdivisionMapping);
  1520. },
  1521. };
  1522.  
  1523. /** @type {number} */
  1524. const DEFAULT_VISIBLE_AT_ZOOM = 18;
  1525. /** @type {string} */
  1526. const SETTINGS_STORE_NAME = 'wme_gis_layers_fl';
  1527. /** @type {string} */
  1528. const scriptName = GM_info.script.name;
  1529. /** @type {string} */
  1530. const scriptVersion = GM_info.script.version;
  1531. /** @type {string} */
  1532. const downloadUrl = 'https://greasyfork.org/scripts/369632-wme-gis-layers/code/WME%20GIS%20Layers.user.js';
  1533.  
  1534. /**
  1535. * @typedef {Object} ScriptUpdateMonitorArgs
  1536. * @property {string} [scriptVersion]
  1537. * @property {string} downloadUrl
  1538. * @property {string} [metaUrl]
  1539. * @property {RegExp} [metaRegExp]
  1540. */
  1541.  
  1542. /**
  1543. * @typedef {Object} BootstrapArgs
  1544. * @property {string} [scriptName]
  1545. * @property {string} [scriptId]
  1546. * @property {boolean} [useWazeWrap=false]
  1547. * @property {ScriptUpdateMonitorArgs} [scriptUpdateMonitor]
  1548. * @property {(wmeSdk: Object) => void} [callback]
  1549. */
  1550.  
  1551. /**
  1552. * Initializes WME SDK and starts ScriptUpdateMonitor using bootstrap().
  1553. * @type {Object}
  1554. */
  1555. const sdk = await bootstrap(
  1556. /** @type {BootstrapArgs} */ ({
  1557. scriptUpdateMonitor: { downloadUrl },
  1558. })
  1559. );
  1560.  
  1561. /**
  1562. * @typedef {Object} Offset
  1563. * @property {number} x - X pixel offset
  1564. * @property {number} y - Y pixel offset
  1565. */
  1566.  
  1567. /**
  1568. * @typedef {Object} LayerSettings
  1569. * @property {Offset=} offset - Optional XY offset for a layer.
  1570. */
  1571.  
  1572. /**
  1573. * @typedef {Object} LayerGroupSettings
  1574. * @property {Array<string>} selectedSubL1 - Array of selected sub-L1 region codes.
  1575. * @property {Array<string>} visibleLayers - Array of visible layer IDs in this group.
  1576. * @property {Object.<string, boolean>} collapsedSections - Map of section names to collapsed state (can be empty).
  1577. * @property {string} addrLabelDisplay - Address label display mode ("all" in this sample).
  1578. * @property {boolean} fillParcels - Whether to fill parcels in this group.
  1579. */
  1580.  
  1581. /**
  1582. * @typedef {Object} Settings
  1583. * @property {string} lastVersion - The last version number this script saw, e.g., "2025.08.01.000".
  1584. * @property {Array<string>} visibleLayers - Array of visible layer IDs.
  1585. * @property {boolean} onlyShowApplicableLayers - Whether to show only applicable layers.
  1586. * @property {boolean} onlyShowApplicableLayersZoom - Restrict showing applicable layers to a certain zoom.
  1587. * @property {Array<string>} selectedSubL1 - Selected sub-L1 region codes (e.g., ["CAN-CAN", "USA-CT"]).
  1588. * @property {boolean} enabled - Whether this script is enabled.
  1589. * @property {boolean} fillParcels - Whether to fill parcel polygons.
  1590. * @property {Object.<string, number>} oneTimeAlerts - Map of alert keys to offset numbers (possibly UNIX timestamps or magic numbers).
  1591. * @property {Object.<string, LayerSettings>} layers - Map of layer IDs to layer settings.
  1592. * @property {Object.<string, string>} shortcuts - Map of shortcut IDs to key combo strings, e.g. "2,67".
  1593. * @property {boolean} isPopupVisible - Is the config/settings popup currently visible.
  1594. * @property {boolean} useAcronyms - Whether to use acronyms for certain values.
  1595. * @property {boolean} useTitleCase - Whether to use title case in labels.
  1596. * @property {boolean} useStateHwy - Whether to use "State Hwy" format for roads.
  1597. * @property {boolean} removeNewLines - Whether to remove new lines from names/labels.
  1598. * @property {Object.<string, boolean>} collapsedSections - Map of section names (region codes, etc) to collapsed state.
  1599. * @property {Object.<string, LayerGroupSettings>} layerGroups - Map of group names to per-group settings.
  1600. * @property {string} addrLabelDisplay - Display mode for address labels ("all" in this sample).
  1601. * @property {string} socrataAppToken - Token for Socrata API access.
  1602. * @property {string} [toggleHnsOnlyShortcut] - legacy, only present Pre SDK migration, moved to shortcuts.toggleHnsOnlyShortcut
  1603. * @property {string} [toggleEnabledShortcut] - legacy, only present Pre SDK migration, moved to shortcuts.toggleEnabledShortcut
  1604. *
  1605. * @property {(layerID: string, settingName: string) => *} getLayerSetting - Get a setting for a layer.
  1606. * @property {(layerID: string, settingName: string, value: *) => void} setLayerSetting - Set a setting for a layer.
  1607. * @property {(layerID: string, settingName?: string) => void} removeLayerSetting - Remove a setting or a whole layer.
  1608. */
  1609.  
  1610. /**
  1611. * User and UI settings for script, with utility methods.
  1612. * @type {Settings }
  1613. */
  1614. let settings = /** @type {any} */ ({});
  1615.  
  1616. /** @type {boolean} */
  1617. let ignoreFetch = false;
  1618.  
  1619. /**
  1620. * @typedef {Object} LastToken
  1621. * @property {boolean} cancel - Set to true to request the operation to cancel.
  1622. * @property {Array} features - Array of features being processed.
  1623. * @property {number} layersProcessed - Number of layers processed.
  1624. */
  1625.  
  1626. /**
  1627. * Tracks the current in-progress async request and provides control/status.
  1628. * @type {LastToken}
  1629. */
  1630. let lastToken = { cancel: false, features: [], layersProcessed: 0 };
  1631.  
  1632. /**
  1633. * @typedef {Object} UserSession
  1634. * @property {boolean} isAreaManager
  1635. * @property {boolean} isCountryManager
  1636. * @property {number} rank
  1637. * @property {string} userName
  1638. */
  1639.  
  1640. /** @type {UserSession|null} */
  1641. let userInfo = null;
  1642.  
  1643. // Variables to store Label popup position and selected layer
  1644. /** @type {Object.<string, Set<string>>} */
  1645. const layerLabels = {};
  1646. /** @type {boolean} */
  1647. let isPopupVisible = false;
  1648. /** @type {{left: string, top: string}} */
  1649. const popupPosition = { left: '50%', top: '50%' };
  1650. /** @type {string | null} */
  1651. let popupActiveLayer = null;
  1652. /** @type {boolean} */
  1653. let useAcronyms = false;
  1654. /** @type {boolean} */
  1655. let useTitleCase = false;
  1656. /** @type {boolean} */
  1657. let useStateHwy = false;
  1658. /** @type {boolean} */
  1659. let removeNewLines = false;
  1660.  
  1661. /** @type {boolean} */
  1662. const DEBUG = true;
  1663.  
  1664. /**
  1665. * Error logging utility.
  1666. * @param {string} message
  1667. * @param {...any} args
  1668. */
  1669. function logError(message, ...args) {
  1670. console.error(`${scriptName}:`, message, ...args);
  1671. }
  1672.  
  1673. /**
  1674. * Logs a debug message if DEBUG is enabled.
  1675. * @param {string} message
  1676. * @param {...any} args
  1677. */
  1678. function logDebug(message, ...args) {
  1679. if (DEBUG) console.debug(`${scriptName}:`, message, ...args);
  1680. }
  1681.  
  1682. let _layerSettingsDialog;
  1683.  
  1684. /**
  1685. * Dialog for configuring GIS layer settings in the UI.
  1686. * Provides shift controls, visibility at zoom, and offset reset.
  1687. */
  1688. class LayerSettingsDialog {
  1689. #gisLayer;
  1690. #minVisibleAtZoom = 12;
  1691. #maxVisibleAtZoom = 22;
  1692. #titleText;
  1693. #visibleAtZoomInput;
  1694.  
  1695. constructor() {
  1696. this.#titleText = $('<span>');
  1697.  
  1698. const closeButton = $('<span>', {
  1699. style: 'cursor:pointer;padding-left:14px;font-size:20px;color:#eaf6ff;float:right;',
  1700. class: 'fa fa-window-close',
  1701. title: 'Close',
  1702. }).on('click', () => this.#onCloseButtonClick());
  1703.  
  1704. const shiftUpButton = LayerSettingsDialog.#createShiftButton('fa-angle-up').on('click', () => this.#onShiftButtonClick(0, 1));
  1705. const shiftLeftButton = LayerSettingsDialog.#createShiftButton('fa-angle-left').on('click', () => this.#onShiftButtonClick(-1, 0));
  1706. const shiftRightButton = LayerSettingsDialog.#createShiftButton('fa-angle-right').on('click', () => this.#onShiftButtonClick(1, 0));
  1707. const shiftDownButton = LayerSettingsDialog.#createShiftButton('fa-angle-down').on('click', () => this.#onShiftButtonClick(0, -1));
  1708. const resetOffsetButton = $('<button>', {
  1709. class: 'form-control',
  1710. style:
  1711. 'height: 26px; width: auto; padding: 2px 12px 2px 12px; display: inline-block; float: right; font-weight:bold;background:#4d6a88;color:#eaf6ff;border-radius:5px;border:1px solid #4d6a88;margin-left:4px;',
  1712. })
  1713. .text('Reset')
  1714. .on('click', () => this.#onResetOffsetButtonClick());
  1715.  
  1716. this._dialogDiv = $('<div>', {
  1717. style:
  1718. // Modern blue theme & rounded & drop shadow
  1719. 'position: fixed; top: 15%; left: 400px; width: 235px; z-index: 100; background: #73a9bd;' +
  1720. 'border-width: 1px; border-style: solid; border-radius: 14px; box-shadow: 5px 6px 14px rgba(0,0,0,0.58);' +
  1721. 'border-color: #50667b; padding: 0; font-family: inherit;',
  1722. }).append(
  1723. $('<div>').append(
  1724. // HEADER
  1725. $('<div>', {
  1726. style: 'border-radius:14px 14px 0px 0px; padding: 5px 5px 5px 5px; color: #fff; background:#4d6a88;font-weight: bold; text-align:left; font-size:17px;',
  1727. }).append(this.#titleText, closeButton),
  1728. // BODY
  1729. $('<div>', { style: 'padding: 5px 5px 5px 5px;' }).append(
  1730. $('<div>', {
  1731. style: 'border-radius: 7px; width: 100%; padding:8px 6px 10px 8px; background:#d6e6f3; margin-bottom:6px; margin-right:0; box-sizing:border-box;',
  1732. }).append(
  1733. resetOffsetButton,
  1734. $('<input>', {
  1735. type: 'radio',
  1736. id: 'gisLayerShiftAmt1',
  1737. name: 'gisLayerShiftAmt',
  1738. value: '1',
  1739. checked: 'checked',
  1740. style: 'margin-left:4px;accent-color:#4d6a88;',
  1741. }),
  1742. $('<label>', { for: 'gisLayerShiftAmt1', style: 'margin-right:8px;margin-left:2px;color:#4d6a88;font-weight:600;font-size:13px;' }).text('1m'),
  1743. $('<input>', {
  1744. type: 'radio',
  1745. id: 'gisLayerShiftAmt10',
  1746. name: 'gisLayerShiftAmt',
  1747. value: '10',
  1748. style: 'margin-left: 6px;accent-color:#4d6a88;',
  1749. }),
  1750. $('<label>', { for: 'gisLayerShiftAmt10', style: 'color:#4d6a88;font-weight:600;font-size:13px;' }).text('10m'),
  1751. $('<div>', { style: 'padding: 6px 0 0 0;' }).append(
  1752. $('<table>', { style: 'table-layout:fixed; width:70px; height:84px; margin:auto;' }).append(
  1753. $('<tr>').append($('<td>', { align: 'center', style: 'width:20px;height:28px;' }), $('<td>', { align: 'center', style: 'width:20px;' }).append(shiftUpButton), $('<td>')),
  1754. $('<tr>').append($('<td>', { align: 'center' }).append(shiftLeftButton), $('<td>', { align: 'center' }), $('<td>', { align: 'center' }).append(shiftRightButton)),
  1755. $('<tr>').append($('<td>', { align: 'center' }), $('<td>', { align: 'center' }).append(shiftDownButton), $('<td>', { align: 'center' }))
  1756. )
  1757. )
  1758. ),
  1759. $('<div>', {
  1760. style: 'border-radius: 7px; width:100%; padding:12px 8px 8px 10px; margin-top:2px; background: #d6e6f3; margin-right:0px;box-sizing:border-box;',
  1761. }).append(
  1762. $('<div>', { style: 'display: flex; justify-content: flex-end; margin-bottom: 8px;' }).append(
  1763. $('<button>', {
  1764. class: 'form-control',
  1765. style: 'height: 26px; width:auto;padding: 2px 12px 2px 12px; background:#4d6a88;color:#eaf6ff;border:1px solid #4d6a88;font-weight:bold;border-radius:5px;',
  1766. })
  1767. .text('Reset')
  1768. .on('click', this.#onResetVisibleAtZoomClick.bind(this))
  1769. ),
  1770. $('<div>').append(
  1771. $('<label>', { for: 'visible-at-zoom-input', style: 'font-size:14px;font-weight:bold;color:#4d6a88;' }).text('Visible at zoom:'),
  1772. (this.#visibleAtZoomInput = $('<input>', {
  1773. type: 'number',
  1774. id: 'visible-at-zoom-input',
  1775. min: this.#minVisibleAtZoom,
  1776. max: this.#maxVisibleAtZoom,
  1777. style: 'margin-left: 6px; width:46px;font-size:13px;border-radius:3px;',
  1778. }).change((v) => this.#onVisibleAtZoomChange(v)))
  1779. ),
  1780. $('<div>', { style: 'font-size: 12.5px; color: #4d6a88; margin-top:5px;white-space:pre-line;text-align:left;' }).text(
  1781. 'Pan or zoom the map to refresh after changing.\n\nSetting this value too low may cause performance issues.'
  1782. )
  1783. )
  1784. )
  1785. )
  1786. );
  1787.  
  1788. this.hide();
  1789. this._dialogDiv.appendTo('body');
  1790.  
  1791. if (typeof jQuery.ui !== 'undefined') {
  1792. const that = this;
  1793. this._dialogDiv.draggable({
  1794. stop() {
  1795. that._dialogDiv.css('height', '');
  1796. },
  1797. });
  1798. }
  1799. }
  1800.  
  1801. get gisLayer() {
  1802. return this.#gisLayer;
  1803. }
  1804.  
  1805. set gisLayer(value) {
  1806. if (value !== this.#gisLayer) {
  1807. this.#gisLayer = value;
  1808. this.#titleText.text(this.#gisLayer.name);
  1809. this.#initVisibleAtZoomInput();
  1810. }
  1811. }
  1812.  
  1813. #initVisibleAtZoomInput() {
  1814. this.#visibleAtZoomInput.val(getGisLayerVisibleAtZoom(this.#gisLayer));
  1815. }
  1816.  
  1817. getShiftAmount() {
  1818. return $('input[name=gisLayerShiftAmt]:checked').val();
  1819. }
  1820.  
  1821. show() {
  1822. this._dialogDiv.show();
  1823. }
  1824.  
  1825. hide() {
  1826. this._dialogDiv.hide();
  1827. }
  1828.  
  1829. #onResetVisibleAtZoomClick() {
  1830. settings.removeLayerSetting(this.#gisLayer.id, 'visibleAtZoom');
  1831. this.#initVisibleAtZoomInput();
  1832. }
  1833.  
  1834. #onCloseButtonClick() {
  1835. this.hide();
  1836. }
  1837.  
  1838. #onVisibleAtZoomChange() {
  1839. const min = this.#minVisibleAtZoom;
  1840. const max = this.#maxVisibleAtZoom;
  1841. let value = parseInt(this.#visibleAtZoomInput.val(), 10);
  1842.  
  1843. if (value < min) {
  1844. value = min;
  1845. this.#visibleAtZoomInput.val(value);
  1846. } else if (value > max) {
  1847. value = max;
  1848. this.#visibleAtZoomInput.val(value);
  1849. }
  1850.  
  1851. settings.setLayerSetting(this.#gisLayer.id, 'visibleAtZoom', value);
  1852. saveSettingsToStorage();
  1853. }
  1854.  
  1855. #onShiftButtonClick(x, y) {
  1856. const shiftAmount = this.getShiftAmount();
  1857. x *= shiftAmount;
  1858. y *= shiftAmount;
  1859. this.#shiftLayerFeatures(x, y);
  1860. const { id } = this.gisLayer;
  1861. let offset = settings.getLayerSetting(id, 'offset');
  1862. if (!offset) {
  1863. offset = { x: 0, y: 0 };
  1864. settings.setLayerSetting(id, 'offset', offset);
  1865. }
  1866. offset.x += x;
  1867. offset.y += y;
  1868. saveSettingsToStorage();
  1869. }
  1870.  
  1871. #onResetOffsetButtonClick() {
  1872. const offset = settings.getLayerSetting(this.gisLayer.id, 'offset');
  1873. if (offset) {
  1874. this.#shiftLayerFeatures(offset.x * -1, offset.y * -1);
  1875. settings.removeLayerSetting(this.gisLayer.id, 'offset');
  1876. saveSettingsToStorage();
  1877. }
  1878. }
  1879.  
  1880. #shiftLayerFeatures(x, y) {
  1881. //Given the inputs have been updated to Degrees, shifting by meters still makes sense and works.
  1882. const { isRoadLayer } = this.gisLayer;
  1883. let featureCollection = isRoadLayer ? roadFeatures : defaultFeatures;
  1884. const { distance, bearing } = LayerSettingsDialog.#calculateDistanceAndBearing(x, y);
  1885. featureCollection = featureCollection.filter((f) => f.properties.layerID === this.gisLayer.id).map((f) => turf.transformTranslate(f, distance, bearing, { units: 'meters' }));
  1886. if (isRoadLayer) {
  1887. roadFeatures = featureCollection;
  1888. } else {
  1889. defaultFeatures = featureCollection;
  1890. }
  1891. const layerName = isRoadLayer ? ROAD_LAYER_NAME : DEFAULT_LAYER_NAME;
  1892. const featureIds = featureCollection.map((f) => f.id);
  1893. sdk.Map.removeFeaturesFromLayer({ layerName, featureIds });
  1894. sdk.Map.addFeaturesToLayer({ layerName, features: featureCollection });
  1895. }
  1896.  
  1897. /**
  1898. * Calculates the total distance and bearing from X and Y meter offsets.
  1899. * @param {number} dx_meters - X offset in meters (east/west).
  1900. * @param {number} dy_meters - Y offset in meters (north/south).
  1901. * @returns {{distance: number, bearing: number}}
  1902. */
  1903. static #calculateDistanceAndBearing(dx_meters, dy_meters) {
  1904. const distance = Math.sqrt(dx_meters ** 2 + dy_meters ** 2);
  1905.  
  1906. // Calculate bearing in radians
  1907. // Math.atan2(y, x) returns angle in radians between -PI and PI
  1908. // Need to adjust to be 0-360 degrees clockwise from North
  1909. const bearing_rad = Math.atan2(dx_meters, dy_meters); // dx_meters is 'x' (east), dy_meters is 'y' (north)
  1910.  
  1911. // Convert to degrees and adjust for 0-360, clockwise from North
  1912. let bearing_deg = bearing_rad * (180 / Math.PI);
  1913. bearing_deg = (bearing_deg + 360) % 360; // Ensure positive and within 0-360 range
  1914.  
  1915. return { distance, bearing: bearing_deg };
  1916. }
  1917.  
  1918. static #createShiftButton(fontAwesomeClass) {
  1919. return $('<button>', {
  1920. class: 'form-control',
  1921. style:
  1922. 'cursor:pointer;font-size:15px;padding: 3px;border-radius: 8px;width: 25px;height: 25px;background:#eaf6ff;border:1px solid #8ea0b7;color:#4d6a88;box-shadow:0 1.5px 4px #b6d0eb66;margin:1.5px;',
  1923. }).append($('<i>', { class: 'fa', style: 'vertical-align: middle;font-size:16px;' }).addClass(fontAwesomeClass));
  1924. }
  1925. }
  1926.  
  1927. function loadSettingsFromStorage() {
  1928. const defaultSettings = {
  1929. lastVersion: '',
  1930. visibleLayers: [],
  1931. onlyShowApplicableLayers: false,
  1932. onlyShowApplicableLayersZoom: false,
  1933. selectedSubL1: [],
  1934. enabled: true,
  1935. fillParcels: false,
  1936. oneTimeAlerts: {},
  1937. layers: {},
  1938. shortcuts: {},
  1939. isPopupVisible: false,
  1940. useAcronyms: false,
  1941. useTitleCase: false,
  1942. useStateHwy: false,
  1943. removeNewLines: false,
  1944. collapsedSections: {},
  1945. layerGroups: {},
  1946. addrLabelDisplay: 'all',
  1947. socrataAppToken: '',
  1948. getLayerSetting: function () {
  1949. return undefined;
  1950. },
  1951. setLayerSetting: function () {},
  1952. removeLayerSetting: function () {},
  1953. };
  1954.  
  1955. let loadedSettings = {};
  1956. let migrated = false; // Track if any migration occurred
  1957. const storedSettings = localStorage.getItem(SETTINGS_STORE_NAME);
  1958.  
  1959. if (storedSettings) {
  1960. try {
  1961. const parsed = JSON.parse(storedSettings);
  1962. if (parsed && typeof parsed === 'object') {
  1963. loadedSettings = parsed;
  1964. } else {
  1965. logDebug(`Stored settings under key "${SETTINGS_STORE_NAME}" were not a valid object.`);
  1966. }
  1967. } catch (e) {
  1968. logError(`Failed to parse settings from localStorage key "${SETTINGS_STORE_NAME}":`, e);
  1969. }
  1970. }
  1971.  
  1972. // ---- MIGRATION: old selectedStates -> selectedSubL1 ----
  1973. if (loadedSettings.selectedStates && Array.isArray(loadedSettings.selectedStates)) {
  1974. if (!Array.isArray(loadedSettings.selectedSubL1)) loadedSettings.selectedSubL1 = [];
  1975. loadedSettings.selectedStates.forEach((stateCode) => {
  1976. const converted = `USA-${stateCode}`;
  1977. if (!loadedSettings.selectedSubL1.includes(converted)) {
  1978. loadedSettings.selectedSubL1.push(converted);
  1979. }
  1980. });
  1981. delete loadedSettings.selectedStates;
  1982. migrated = true;
  1983. logDebug('Migrated legacy selectedStates to selectedSubL1');
  1984. }
  1985.  
  1986. // --- MERGE with defaults ---
  1987. settings = { ...defaultSettings, ...loadedSettings };
  1988.  
  1989. // --- Save if migrated ---
  1990. if (migrated) {
  1991. saveSettingsToStorage();
  1992. logDebug('Settings saved after migration');
  1993. }
  1994.  
  1995. // --- Assign globals ---
  1996. isPopupVisible = settings.isPopupVisible;
  1997. useAcronyms = settings.useAcronyms;
  1998. useTitleCase = settings.useTitleCase;
  1999. useStateHwy = settings.useStateHwy;
  2000. removeNewLines = settings.removeNewLines;
  2001.  
  2002. // --- Utility layer functions ---
  2003. settings.getLayerSetting = function getLayerSetting(layerID, settingName) {
  2004. const layerSettings = this.layers[layerID];
  2005. if (!layerSettings) {
  2006. return undefined;
  2007. }
  2008. return layerSettings[settingName];
  2009. };
  2010.  
  2011. settings.setLayerSetting = function setLayerSetting(layerID, settingName, value) {
  2012. let layerSettings = this.layers[layerID];
  2013. if (!layerSettings) {
  2014. layerSettings = {};
  2015. this.layers[layerID] = layerSettings;
  2016. }
  2017. layerSettings[settingName] = value;
  2018. };
  2019.  
  2020. // Remove an individual setting or the entire layer if no settingName
  2021. settings.removeLayerSetting = function removeLayerSetting(layerID, settingName) {
  2022. if (typeof settingName === 'undefined') {
  2023. // Remove the entire layer settings block
  2024. delete this.layers[layerID];
  2025. } else {
  2026. const layerSettings = this.layers[layerID];
  2027. if (layerSettings) {
  2028. delete layerSettings[settingName];
  2029. // If the layerSettings object is now empty, remove the layer entirely
  2030. if (Object.keys(layerSettings).length === 0) {
  2031. delete this.layers[layerID];
  2032. }
  2033. }
  2034. }
  2035. };
  2036.  
  2037. // --- Legacy shortcut keys migration ---
  2038. if (settings.toggleHnsOnlyShortcut) {
  2039. settings.shortcuts.toggleHnsOnly = settings.toggleHnsOnlyShortcut;
  2040. delete settings.toggleHnsOnlyShortcut;
  2041. }
  2042. if (settings.toggleEnabledShortcut) {
  2043. settings.shortcuts.toggleEnabled = settings.toggleEnabledShortcut;
  2044. delete settings.toggleEnabledShortcut;
  2045. }
  2046. }
  2047.  
  2048. /**
  2049. * Saves current application settings and shortcut definitions to localStorage.
  2050. * Serializes the `settings` object and stores under the key `SETTINGS_STORE_NAME`.
  2051. *
  2052. * @typedef {Object} Shortcut
  2053. * @property {string} shortcutId - Unique identifier for the shortcut.
  2054. * @property {string} shortcutKeys - Key combination for activating the shortcut.
  2055. *
  2056. * @returns {void}
  2057. */
  2058. function saveSettingsToStorage() {
  2059. settings.shortcuts = {};
  2060.  
  2061. /** @type {Shortcut[]} */
  2062. const shortcuts = sdk.Shortcuts.getAllShortcuts();
  2063.  
  2064. shortcuts.forEach(
  2065. /** @param {Shortcut} shortcut */
  2066. (shortcut) => {
  2067. settings.shortcuts[shortcut.shortcutId] = shortcut.shortcutKeys;
  2068. }
  2069. );
  2070.  
  2071. settings.lastVersion = scriptVersion;
  2072. settings.isPopupVisible = isPopupVisible;
  2073. settings.useAcronyms = useAcronyms;
  2074. settings.useTitleCase = useTitleCase;
  2075. settings.useStateHwy = useStateHwy;
  2076. settings.removeNewLines = removeNewLines;
  2077.  
  2078. localStorage.setItem(SETTINGS_STORE_NAME, JSON.stringify(settings));
  2079. logDebug('Settings saved');
  2080. }
  2081.  
  2082. /**
  2083. * Returns the maximum allowable offset (in degrees) for a given map zoom level.
  2084. * If no matching zoom level is found, uses the most detailed (22).
  2085. * @param {number} zoomLevel
  2086. * @returns {number}
  2087. */
  2088. function getMaxAllowableOffsetForZoom(zoomLevel) {
  2089. const zoomToOffsetMap = {
  2090. 12: 0.0009, // ~100 meters
  2091. 13: 0.00045, // ~50 meters
  2092. 14: 0.000225, // ~25 meters
  2093. 15: 0.0001125, // ~12 meters
  2094. 16: 0.000056, // ~6 meters
  2095. 17: 0.000028, // ~3 meters
  2096. 18: 0.000014, // ~1.5 meters
  2097. 19: 0.000007, // ~1 meter
  2098. 20: 0.000007, // ~1 meter
  2099. 21: 0.000007, // ~1 meter
  2100. 22: 0.000007, // ~1 meter
  2101. };
  2102. // Always round to nearest integer for lookup
  2103. const key = Math.round(zoomLevel);
  2104. return zoomToOffsetMap[key] !== undefined ? zoomToOffsetMap[key] : zoomToOffsetMap[22];
  2105. }
  2106.  
  2107. /**
  2108. * Build a feature query URL for a GIS layer given a bounding extent and zoom.
  2109. *
  2110. * @param {[number, number, number, number]} extent - [xmin, ymin, xmax, ymax] bounding box in EPSG:4326
  2111. * @param {GisLayer} gisLayer - Layer definition object
  2112. * @param {number} zoom - Display zoom level
  2113. * @returns {string} The fully constructed query URL, or '' on error
  2114. */
  2115. function getUrl(extent, gisLayer, zoom) {
  2116. /**
  2117. * Utility: gets fields or returns empty array
  2118. * @param {unknown} fields
  2119. * @returns {string[]}
  2120. */
  2121. const getFields = (fields) => (Array.isArray(fields) ? fields.slice() : []);
  2122.  
  2123. // ----- ArcGIS -----
  2124. if (gisLayer.platform === 'ArcGIS' || !gisLayer.platform) {
  2125. const layerOffset = settings.getLayerSetting(gisLayer.id, 'offset') ?? { x: 0, y: 0 };
  2126. const geometry = {
  2127. xmin: extent[0] - layerOffset.x,
  2128. ymin: extent[1] - layerOffset.y,
  2129. xmax: extent[2] - layerOffset.x,
  2130. ymax: extent[3] - layerOffset.y,
  2131. spatialReference: { wkid: 4326 },
  2132. };
  2133. const maxAllowableOffset = getMaxAllowableOffsetForZoom(zoom);
  2134. const fields = getFields(gisLayer.labelFields).join(',');
  2135.  
  2136. const params = [
  2137. `geometry=${encodeURIComponent(JSON.stringify(geometry))}`,
  2138. `outFields=${encodeURIComponent(fields)}`,
  2139. 'returnGeometry=true',
  2140. 'spatialRel=esriSpatialRelIntersects',
  2141. 'geometryType=esriGeometryEnvelope',
  2142. 'inSR=4326',
  2143. 'outSR=4326',
  2144. 'f=json',
  2145. `maxAllowableOffset=${maxAllowableOffset}`,
  2146. gisLayer.where ? `where=${encodeURIComponent(gisLayer.where)}` : '',
  2147. ].filter(Boolean);
  2148.  
  2149. const url = `${gisLayer.url}/query?${params.join('&')}`;
  2150. logDebug(`ArcGIS Request URL: ${url}`);
  2151. return url;
  2152. }
  2153.  
  2154. //----- Socrata V2 and V3 -----
  2155. if (gisLayer.platform === 'SocrataV2' || gisLayer.platform === 'SocrataV3') {
  2156. const labelFields = getFields(gisLayer.labelFields);
  2157. if (labelFields.length === 0) {
  2158. logDebug("labelFields must have the field name that holds the geometry points as the first element for Socrata URLs! Example: 'location', 'the_geom', 'geometry', etc.");
  2159. return '';
  2160. }
  2161. const geomField = labelFields[0];
  2162.  
  2163. // Build bounding box with buffer (north, west, south, east)
  2164. const bufferDeg = 0.001;
  2165. const [xmin, ymin, xmax, ymax] = extent;
  2166. const boxClause = `within_box(${geomField},${ymax + bufferDeg},${xmin - bufferDeg},${ymin - bufferDeg},${xmax + bufferDeg})`;
  2167. const isNotNullClause = `${geomField} IS NOT NULL`;
  2168.  
  2169. // User WHERE (if any)
  2170. let customWhere = '';
  2171. if (typeof gisLayer.where === 'string' && gisLayer.where.trim()) {
  2172. customWhere = gisLayer.where.trim();
  2173. }
  2174.  
  2175. // ----- V2: SODA -----
  2176. if (gisLayer.platform === 'SocrataV2') {
  2177. const selectClause = labelFields.join(',');
  2178. const whereParts = [];
  2179. if (customWhere) whereParts.push(customWhere);
  2180. whereParts.push(boxClause);
  2181. whereParts.push(isNotNullClause);
  2182. const whereClause = whereParts.length ? `$where=${encodeURIComponent(whereParts.join(' AND '))}` : '';
  2183. const params = [`$select=${encodeURIComponent(selectClause)}`, whereClause, `$limit=3000`].filter(Boolean);
  2184. let urlBase = gisLayer.url + '.geojson';
  2185. const url = urlBase + '?' + params.join('&');
  2186. logDebug(`SocrataV2: Request URL: ${url}`);
  2187. return url;
  2188. }
  2189.  
  2190. // ----- V3: "SQL-in-query-param" pattern -----
  2191. if (gisLayer.platform === 'SocrataV3') {
  2192. // V3 only supports SQL-in-query, **not** SoQL style params.
  2193. // Build SQL string: SELECT ..., ... WHERE ... AND ... LIMIT ...
  2194. const selectFieldsList = labelFields.join(', ');
  2195. const whereParts = [];
  2196. if (customWhere) whereParts.push(customWhere);
  2197. whereParts.push(boxClause);
  2198. whereParts.push(isNotNullClause);
  2199. const whereSQL = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
  2200. const sql = `SELECT ${selectFieldsList} ${whereSQL} LIMIT 3000`;
  2201. let urlBase = gisLayer.url + '/query.geojson';
  2202. // NOTE: URL-encode the entire SQL string as the query's value
  2203. const url = `${urlBase}?query=${encodeURIComponent(sql)}`;
  2204. logDebug(`SocrataV3: Request URL: ${url}`);
  2205. return url;
  2206. }
  2207. }
  2208.  
  2209. // ----- Unknown -----
  2210. logDebug('getUrl fallback (no matching platform type found for:', gisLayer);
  2211. return '';
  2212. }
  2213.  
  2214. function hashString(value) {
  2215. let hash = 0;
  2216. if (value.length === 0) return hash;
  2217. for (let i = 0; i < value.length; i++) {
  2218. const chr = value.charCodeAt(i);
  2219. // eslint-disable-next-line no-bitwise
  2220. hash = (hash << 5) - hash + chr;
  2221. // eslint-disable-next-line no-bitwise
  2222. hash |= 0; // Convert to 32bit integer
  2223. }
  2224. return hash;
  2225. }
  2226.  
  2227. /**
  2228. * Retrieves the current map extent coordinates in the WGS84 projection.
  2229. *
  2230. * @param {'wgs84'|'CRS84'|'4326'|'EPSG:4326'} [projection='wgs84'] - Optional projection name
  2231. * (case-insensitive; allowed values: 'wgs84', 'CRS84', '4326', 'EPSG:4326').
  2232. * @returns {[number, number, number, number]} An array of [leftBottomLongitude, leftBottomLatitude, rightTopLongitude, rightTopLatitude] in WGS84.
  2233. * @throws {Error} If an unsupported projection type is specified.
  2234. */
  2235. function getMapExtent(projection = 'wgs84') {
  2236. const wgs84Extent = sdk.Map.getMapExtent(); // [xmin, ymin, xmax, ymax] in WGS84
  2237.  
  2238. const wgs84Projections = ['wgs84', 'CRS84', '4326', 'EPSG:4326'];
  2239.  
  2240. if (wgs84Projections.includes(projection.toLowerCase())) {
  2241. return [wgs84Extent[0], wgs84Extent[1], wgs84Extent[2], wgs84Extent[3]];
  2242. } else {
  2243. throw new Error('Unsupported projection type');
  2244. }
  2245. }
  2246.  
  2247. /**
  2248. * Returns the "visibleAtZoom" setting for a GIS layer,
  2249. * considering layer settings overrides, the layer's own property,
  2250. * and falling back to a global default if neither is found.
  2251. *
  2252. * @param {GisLayer} gisLayer - The GIS layer configuration object.
  2253. * @returns {number} The zoom level at which the layer should be visible.
  2254. */
  2255. function getGisLayerVisibleAtZoom(gisLayer) {
  2256. // Fetch override settings
  2257. const overrideVisibleAtZoom = settings.getLayerSetting(gisLayer.id, 'visibleAtZoom');
  2258. if (typeof overrideVisibleAtZoom === 'number') {
  2259. return overrideVisibleAtZoom;
  2260. }
  2261.  
  2262. const val = gisLayer.visibleAtZoom;
  2263. if (typeof val === 'number') {
  2264. return val;
  2265. }
  2266.  
  2267. return DEFAULT_VISIBLE_AT_ZOOM;
  2268. }
  2269.  
  2270. /**
  2271. * Calculates the zoom level at which labels for a GIS layer should become visible.
  2272. * If the layer has a non-null 'labelsVisibleAtZoom' property, computes the offset from the layer's 'visibleAtZoom'
  2273. * (with a fallback to a default if 'visibleAtZoom' is missing).
  2274. * Otherwise, defaults to 'layerVisibleAtZoom + 1'.
  2275. * Ensures the result is at least 1.
  2276. *
  2277. * @param {GisLayer} gisLayer - The GIS layer configuration object.
  2278. * @param {number} layerVisibleAtZoom - The zoom level at which the layer itself becomes visible.
  2279. * @returns {number} The computed zoom level at which the labels should be visible (>= 1).
  2280. */
  2281. function getGisLayerLabelsVisibleAtZoom(gisLayer, layerVisibleAtZoom) {
  2282. layerVisibleAtZoom = +layerVisibleAtZoom;
  2283.  
  2284. if (gisLayer.labelsVisibleAtZoom != null) {
  2285. const baseVisibleAtZoom = gisLayer.visibleAtZoom != null ? +gisLayer.visibleAtZoom : DEFAULT_VISIBLE_AT_ZOOM;
  2286. let labelsVisibleAtZoom = layerVisibleAtZoom + (+gisLayer.labelsVisibleAtZoom - baseVisibleAtZoom);
  2287.  
  2288. if (labelsVisibleAtZoom < 1) labelsVisibleAtZoom = 1;
  2289. return labelsVisibleAtZoom;
  2290. } else {
  2291. let labelsVisibleAtZoom = layerVisibleAtZoom + 1;
  2292. if (labelsVisibleAtZoom < 1) labelsVisibleAtZoom = 1;
  2293. return labelsVisibleAtZoom;
  2294. }
  2295. }
  2296.  
  2297. /**
  2298. * Asynchronously determines which geographical regions are visible within the current map viewport.
  2299. *
  2300. * Retrieves the current map extent in WGS84, constructs a {@link ViewportBBox},
  2301. * and passes it to {@link WmeGisLBBOX.whatsInView} with high-precision intersection checks.
  2302. * The results are stored in the global (or upper-scope) `_whatsInView` variable, typed as {@link WhatsInViewResult}.
  2303. *
  2304. * Steps:
  2305. * 1. Gets current map extent in the "wgs84" coordinate system.
  2306. * 2. Converts extent into a {@link ViewportBBox} with properties `minLon`, `minLat`, `maxLon`, `maxLat`.
  2307. * 3. Calls {@link WmeGisLBBOX.whatsInView} with high-precision enabled and `returnGeoJson` disabled.
  2308. * 4. Stores the detailed intersecting regions in `_whatsInView`.
  2309. *
  2310. * @returns {Promise<void>} The results are assigned to `_whatsInView` (type: {@link WhatsInViewResult})
  2311. */
  2312. async function whatsInView() {
  2313. const extentWgs84 = getMapExtent('wgs84');
  2314. const highPrecision = true;
  2315. const viewportBbox = {
  2316. minLon: extentWgs84[0],
  2317. minLat: extentWgs84[1],
  2318. maxLon: extentWgs84[2],
  2319. maxLat: extentWgs84[3],
  2320. };
  2321.  
  2322. /** @type {WhatsInViewResult} */
  2323. _whatsInView = await WmeGisLBBOX.whatsInView(viewportBbox, highPrecision, false);
  2324. }
  2325.  
  2326. /**
  2327. * Returns an array of fetchable GIS layers after applying multiple validation and filtering criteria.
  2328. *
  2329. * Checks performed include:
  2330. * - Minimum map zoom (from SDK) must be >= 12.
  2331. * - Layer must be enabled (`enabled === 1`).
  2332. * - Layer must have a non-empty and defined URL.
  2333. * - Layer's subdivision L1 (country/subL1) must match current settings selection.
  2334. * - If `checkVisibility` is true, the layer's ID must be present in the set of visible layers from settings.
  2335. * - If `checkZoomVisibility` is true, the layer must be visible for the current zoom level.
  2336. * - Layer must match a country/subdivision actually visible in the map view (from `_whatsInView`).
  2337. * - If the layer has subdivision level 2 (`subL2`), further filtered by active subdivision in view.
  2338. *
  2339. * @param {boolean} [checkVisibility=true] - If true, check whether each layer is visible in settings.
  2340. * @param {boolean} [checkZoomVisibility=true] - If true, filter layers by their zoom visibility constraints.
  2341. * @returns {GisLayer[]} Array of `GISLayer` objects that passed all checks and are eligible for fetching.
  2342. */
  2343. function getFetchableLayers(checkVisibility = true, checkZoomVisibility = true) {
  2344. const zoom = sdk.Map.getZoomLevel();
  2345. // If zoom level is below 12, log a message and return an empty array, as layers won't be fetched
  2346. if (zoom < 12) {
  2347. logDebug(`No layers fetched, zoom level is < 12!`);
  2348. return [];
  2349. }
  2350. const fetchableLayers = []; // Array to hold fetchable layer IDs
  2351. // Filter the GIS layers based on multiple conditions to determine which are fetchable
  2352. const filteredLayers = _gisLayers.filter((gisLayer) => {
  2353. if (gisLayer.enabled !== 1) return false; // Check if the layer is enabled; skip it if not
  2354.  
  2355. // Ensure the layer has a valid URL; skip if it is empty or undefined
  2356. if (!gisLayer.url || gisLayer.url.trim().length === 0) return false;
  2357.  
  2358. // Check if the country subdivision level 1 is selected
  2359. if (!settings.selectedSubL1.includes(gisLayer.countrySubL1)) return false;
  2360.  
  2361. // Check if the layer ID is saved in settings as visible - turn off when call from "Only show applicable layers"
  2362. if (checkVisibility) {
  2363. if (!settings.visibleLayers.includes(gisLayer.id)) return false;
  2364. }
  2365.  
  2366. if (checkZoomVisibility) {
  2367. if (zoom < getGisLayerVisibleAtZoom(gisLayer)) return false; // Check if the layer is visible at the current zoom level
  2368. }
  2369.  
  2370. // Find the country data from the current view based on the ISO_ALPHA3 code
  2371. const countryData = Object.values(_whatsInView).find((countryData) => countryData.ISO_ALPHA3 === gisLayer.country);
  2372.  
  2373. if (!countryData) return false; // Skip if no matching country data is in view
  2374.  
  2375. // Check if the subdivision level 1 (subL1) is in view
  2376. const isSubL1InView = (gisLayer.subL1 && Object.values(countryData.subL1 || {}).some((subL1Data) => subL1Data.subL1_id === gisLayer.subL1)) || countryData.ISO_ALPHA3 === gisLayer.subL1;
  2377.  
  2378. if (!isSubL1InView) return false; // If subL1 is not in view, skip the layer
  2379.  
  2380. const hasSubL2 = gisLayer.subL2 && gisLayer.subL2.length > 0; // Check if the layer has subdivision level 2 names
  2381. if (hasSubL2) {
  2382. // Find the subdivision data entry that matches the layer's subL1 ID
  2383. const subL1DataEntry = Object.entries(countryData.subL1 || {}).find(([_, subL1Details]) => subL1Details.subL1_id === gisLayer.subL1);
  2384. const subL1Data = subL1DataEntry && subL1DataEntry[1]; // Retrieve the actual subL1 data object
  2385. if (!subL1Data) {
  2386. // If no matching subL1 data is found, skip the layer
  2387. return false;
  2388. }
  2389. // Check if any subL2 names from the layer match those in the subL1 data's subL2 list
  2390. const isSubL2InView = gisLayer.subL2.some((subL2Name) => subL1Data.subL2 && Object.keys(subL1Data.subL2).some((subL2InView) => subL2InView.toLowerCase() === subL2Name.toLowerCase()));
  2391. if (!isSubL2InView) return false; // If no subL2 matches are found, skip the layer
  2392. }
  2393.  
  2394. fetchableLayers.push(gisLayer.id); // If the layer passes all checks, add its ID to the fetchable layers list
  2395. return true;
  2396. });
  2397. return filteredLayers;
  2398. }
  2399.  
  2400. /**
  2401. * Updates the visibility of GIS layer checkboxes in the UI according to user-defined settings.
  2402. *
  2403. * Determines which GIS layers should be displayed using the current zoom level and visibility settings:
  2404. * - Shows checkboxes for layers deemed applicable by {@link getFetchableLayers}, which takes into account the current zoom setting from {@link settings.onlyShowApplicableLayersZoom}.
  2405. * - Alternatively, displays all layers if {@link settings.onlyShowApplicableLayers} is false, ignoring zoom-based filtering.
  2406. * - Hides unapplicable layers when both settings limit their display.
  2407. *
  2408. * Each layer's visibility is updated by showing or hiding the corresponding container element in the DOM.
  2409. *
  2410. * Side Effects:
  2411. * Mutates the UI to show or hide corresponding checkboxes and container elements for each GIS layer.
  2412. *
  2413. * @see getFetchableLayers
  2414. * @see settings
  2415. * @global {Array<GisLayer>} _gisLayers - The list of all GIS layer objects.
  2416. * @global {Object} settings - Application-wide layer filter and zoom settings.
  2417. * @global {function} $ - jQuery selector function to manipulate DOM elements.
  2418. */
  2419. function filterLayerCheckboxes() {
  2420. const applicableLayers = getFetchableLayers(false, settings.onlyShowApplicableLayersZoom);
  2421. _gisLayers.forEach((gisLayer) => {
  2422. const layerContainerId = `#gis-layer-${gisLayer.id}-container`;
  2423. // Default behavior is to hide all layers
  2424. let showLayer = false;
  2425. // Show layer if it's included in applicable layers based on the zoom setting
  2426. if (applicableLayers.includes(gisLayer)) {
  2427. showLayer = true;
  2428. }
  2429. // Show all layers if onlyShowApplicableLayers setting is false
  2430. if (!settings.onlyShowApplicableLayers) {
  2431. showLayer = true;
  2432. }
  2433. // Apply visibility based on computed showLayer logic
  2434. if (showLayer) {
  2435. $(layerContainerId).show();
  2436. $(`#gis-layers-for-${gisLayer.subL1}`).show();
  2437. } else {
  2438. $(layerContainerId).hide();
  2439. $(`#gis-layers-for-${gisLayer.subL1}`).hide();
  2440. }
  2441. });
  2442. }
  2443.  
  2444. const ROAD_ABBR = [
  2445. [/\bAVENUE$/, 'AVE'],
  2446. [/\bCIRCLE$/, 'CIR'],
  2447. [/\bCOURT$/, 'CT'],
  2448. [/\bDRIVE$/, 'DR'],
  2449. [/\bLANE$/, 'LN'],
  2450. [/\bPARK$/, 'PK'],
  2451. [/\bPLACE$/, 'PL'],
  2452. [/\bROAD$/, 'RD'],
  2453. [/\bSTREET$/, 'ST'],
  2454. [/\bTERRACE$/, 'TER'],
  2455. ];
  2456.  
  2457. /**
  2458. * @typedef {Object} LabelProcessingGlobals
  2459. * @property {typeof Number} Number
  2460. * @property {typeof Math} Math
  2461. * @property {typeof Boolean} Boolean
  2462. * @property {typeof parseInt} parseInt
  2463. * @property {typeof Date} Date
  2464. * @property {Object.<string, RegExp>} _regexReplace
  2465. * @property {object} [sdk]
  2466. */
  2467.  
  2468. /** @type {LabelProcessingGlobals} */
  2469. const labelProcessingGlobalVariables = {
  2470. Number,
  2471. Math,
  2472. Boolean,
  2473. parseInt,
  2474. Date,
  2475. _regexReplace,
  2476. };
  2477.  
  2478. /**
  2479. * Processes and generates a display label for a feature/item, using layer label fields,
  2480. * zoom/area constraints, and optional ESTree/JS post-processing logic.
  2481. * Applies address and content shortening based on style rules and settings.
  2482. *
  2483. * @param {GisLayer} gisLayer - GIS layer descriptor (with labelFields, style, processLabel, and possibly labelProcessingError).
  2484. * @param {Object} item - The data source for the feature; may have `.attributes` (ArcGIS), `.properties` (GeoJSON), or fields directly.
  2485. * @param {number} displayLabelsAtZoom - Minimum zoom level at which labels are displayed.
  2486. * @param {number} area - Area of the feature in square meters (used for label display logic).
  2487. * @param {boolean} [isPolyLine=false] - If true, the label logic is specific to polylines.
  2488. * @returns {string} The processed label string for display (may be `''` if label is suppressed or error is present).
  2489. */
  2490. function processLabel(gisLayer, item, displayLabelsAtZoom, area, isPolyLine = false) {
  2491. // --- Allow both ArcGIS and GeoJSON: resolve field source ---
  2492. // If the item has .attributes, use that (ArcGIS); else .properties (GeoJSON); fallback: item itself.
  2493. const fieldValues = item && typeof item === 'object' ? item.attributes || item.properties || item : {};
  2494. let label = '';
  2495.  
  2496. // --- Main label fields, only if zoom/area triggers label ---
  2497. if (sdk.Map.getZoomLevel() >= displayLabelsAtZoom || area >= 1000000) {
  2498. label +=
  2499. gisLayer.labelFields
  2500. ?.map((fieldName) => fieldValues[fieldName])
  2501. .join(' ')
  2502. .trim() ?? '';
  2503.  
  2504. // --- Optional ESTree/JS post-processing if configured ---
  2505. if (gisLayer.processLabel) {
  2506. if (gisLayer.labelProcessingError) {
  2507. label = 'ERROR';
  2508. } else {
  2509. // Provide label and fields to processing context
  2510. const ctx = {
  2511. ...labelProcessingGlobalVariables,
  2512. label,
  2513. fieldValues,
  2514. };
  2515. const result = ESTreeProcessor.execute(gisLayer.processLabel, ctx);
  2516. label = result.output?.trim() ?? '';
  2517. }
  2518. }
  2519. }
  2520.  
  2521. // --- Post-processing for certain styles (e.g., address shorteners) ---
  2522. if (!isPolyLine) {
  2523. if (label && ['points', 'parcels', 'state_points', 'state_parcels'].includes(gisLayer.style)) {
  2524. if (settings.addrLabelDisplay === 'hn') {
  2525. const m = label.match(/^\d+/);
  2526. label = m ? m[0] : '';
  2527. } else if (settings.addrLabelDisplay === 'street') {
  2528. const m = label.match(/^(?:\d+\s)?(.*)/);
  2529. label = m ? m[1].trim() : '';
  2530. } else if (settings.addrLabelDisplay === 'none') {
  2531. label = '';
  2532. }
  2533. }
  2534. }
  2535. return label;
  2536. }
  2537.  
  2538. let lastFeatureId = 0;
  2539. // SDK: Remove these once Map.getFeaturesByProperty is implemented: https://issuetracker.google.com/issues/419596843
  2540. let defaultFeatures = [];
  2541. let roadFeatures = [];
  2542.  
  2543. /**
  2544. * Offsets GeoJSON-like geometry coordinates by a layerOffset {x, y}.
  2545. * Supports: 'Point', 'LineString', 'MultiPoint', 'Polygon', 'MultiLineString', 'MultiPolygon'.
  2546. *
  2547. * @param {{ type: string, coordinates: any }} geometry - The geometry object.
  2548. * @param {{ x: number, y: number }} layerOffset - Offset to apply to all coordinates.
  2549. * @returns {Object} The offset geometry.
  2550. */
  2551. function offsetGeometry(geometry, layerOffset) {
  2552. if (!geometry || !layerOffset) return geometry;
  2553.  
  2554. /**
  2555. * @param {[number, number]} coord
  2556. * @returns {[number, number]}
  2557. */
  2558. function offsetCoord(coord) {
  2559. return [coord[0] + layerOffset.x, coord[1] + layerOffset.y];
  2560. }
  2561.  
  2562. switch (geometry.type) {
  2563. case 'Point':
  2564. // Safe to treat as [number, number]
  2565. return { ...geometry, coordinates: offsetCoord(geometry.coordinates) };
  2566. case 'LineString':
  2567. case 'MultiPoint':
  2568. // Array of [number, number]
  2569. return { ...geometry, coordinates: geometry.coordinates.map(offsetCoord) };
  2570. case 'Polygon':
  2571. case 'MultiLineString':
  2572. // Array of Array of [number, number]
  2573. return {
  2574. ...geometry,
  2575. coordinates: geometry.coordinates.map(
  2576. /**
  2577. * @param {Array<[number, number]>} ring
  2578. */
  2579. (ring) => ring.map(offsetCoord)
  2580. ),
  2581. };
  2582. case 'MultiPolygon':
  2583. // Array of Array of Array of [number, number]
  2584. return {
  2585. ...geometry,
  2586. coordinates: geometry.coordinates.map(
  2587. /**
  2588. * @param {Array<Array<[number, number]>>} poly
  2589. */
  2590. (poly) =>
  2591. poly.map(
  2592. /**
  2593. * @param {Array<[number, number]>} ring
  2594. */
  2595. (ring) => ring.map(offsetCoord)
  2596. )
  2597. ),
  2598. };
  2599. default:
  2600. return geometry;
  2601. }
  2602. }
  2603.  
  2604. /**
  2605. * Clips the geometry of a LineString or MultiLineString feature to the given bounding box ([minX, minY, maxX, maxY]).
  2606. *
  2607. * For non-line features, the function returns the original input feature unchanged.
  2608. * If geometry is outside the bbox or the result is empty, returns null.
  2609. *
  2610. * @param {Object} feature - A GeoJSON Feature object, expected to have a LineString or MultiLineString geometry.
  2611. * @param {number[]} extent - Bounding box as [minX, minY, maxX, maxY].
  2612. * @returns {Object|null}
  2613. * Returns the clipped feature if successful and non-empty,
  2614. * otherwise returns null. For unsupported geometry types, returns the original feature.
  2615. *
  2616. * @example
  2617. * // Clip a geojson line
  2618. * clipLineFeatureToExtent(
  2619. * { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0,0],[10,10]] } },
  2620. * [2,2,8,8]
  2621. * )
  2622. */
  2623. function clipLineFeatureToExtent(feature, extent) {
  2624. if (!feature.geometry || !extent) return feature;
  2625. const type = feature.geometry.type;
  2626. if (type !== 'LineString' && type !== 'MultiLineString') return feature;
  2627. try {
  2628. const clipped = turf.bboxClip(feature, extent);
  2629. // Ensure clipped geometry exists and has coordinates
  2630. if (!clipped.geometry.coordinates || !clipped.geometry.coordinates.length) return null;
  2631. return clipped;
  2632. } catch (e) {
  2633. return null;
  2634. }
  2635. }
  2636.  
  2637. function generateFeatureId() {
  2638. lastFeatureId++;
  2639. return lastFeatureId;
  2640. }
  2641.  
  2642. /**
  2643. * Assigns layer properties and an ID to a GeoJSON feature.
  2644. *
  2645. * Adds or overwrites the following properties of the input feature:
  2646. * - `properties.layerID`: set to `gisLayer.id`
  2647. * - `properties.label`: set to the provided label
  2648. * - `id`: set to a newly generated value from `generateFeatureId()`
  2649. *
  2650. * Modifies the input feature in-place and returns it.
  2651. *
  2652. * @param {Object} feature - A GeoJSON Feature object. Must have a `properties` field (object).
  2653. * @param {GisLayer} gisLayer - Layer object containing at least an `id` property.
  2654. * @param {string} label - The label to assign to the feature's properties.
  2655. * @returns {Object} The modified feature with updated properties and ID.
  2656. *
  2657. * @example
  2658. * const feature = { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [0, 0] } };
  2659. * const layer = { id: 'roads' };
  2660. * assignGisProperties(feature, layer, 'Highway');
  2661. * // => feature.properties.layerID === 'roads'
  2662. * // => feature.properties.label === 'Highway'
  2663. * // => feature.id is set
  2664. */
  2665. function assignGisProperties(feature, gisLayer, label) {
  2666. feature.properties = {
  2667. ...feature.properties,
  2668. layerID: gisLayer.id,
  2669. label,
  2670. };
  2671. feature.id = generateFeatureId();
  2672. return feature;
  2673. }
  2674.  
  2675. /**
  2676. * Deduplicates Point features within the given feature array that are spatially close (within 1 meter)
  2677. * and have labels. Merges labels of duplicates, applying label cleaning and abbreviation.
  2678. * Modifies the original features array in place and returns it.
  2679. *
  2680. * @param {Array} features - Array of GeoJSON features (with properties.label) to deduplicate.
  2681. * @returns {Array} The deduplicated (and possibly relabeled) features array.
  2682. */
  2683. function deduplicatePointFeatures(features) {
  2684. for (let i = 0; i < features.length; i++) {
  2685. const f1 = features[i];
  2686. if (f1.geometry.type === 'Point' && !f1.skipDupeCheck && f1.properties.label) {
  2687. let labels = [f1.properties.label];
  2688. for (let j = i + 1; j < features.length; j++) {
  2689. const f2 = features[j];
  2690. if (f2.geometry.type === 'Point' && !f2.skipDupeCheck && f2.properties.label && turf.distance(f1, f2, { units: 'meters' }) < 1) {
  2691. features.splice(j, 1);
  2692. labels.push(f2.properties.label);
  2693. j--;
  2694. }
  2695. }
  2696. labels = _.uniq(labels);
  2697. if (labels.length > 1) {
  2698. labels.forEach((label, idx) => {
  2699. label = label
  2700. .replace(/\n/g, ' ')
  2701. .replace(/\s{2,}/, ' ')
  2702. .replace(/\bUNIT\s.{1,5}$/i, '')
  2703. .trim();
  2704. ROAD_ABBR.forEach((abbr) => (label = label.replace(abbr[0], abbr[1])));
  2705. labels[idx] = label;
  2706. });
  2707. labels = _.uniq(labels);
  2708. labels.sort();
  2709. if (labels.length > 12) {
  2710. const len = labels.length;
  2711. labels = labels.slice(0, 10);
  2712. labels.push(`(${len - 10} more...)`);
  2713. }
  2714. f1.properties.label = _.uniq(labels).join('\n');
  2715. } else {
  2716. let { label } = f1.properties;
  2717. ROAD_ABBR.forEach((abbr) => (label = label.replace(abbr[0], abbr[1])));
  2718. f1.properties.label = label;
  2719. }
  2720. }
  2721. }
  2722. return features;
  2723. }
  2724.  
  2725. /**
  2726. * Updates the given GIS map layer with a new set of features.
  2727. *
  2728. * - Removes all features belonging to the specified gisLayer from the appropriate global feature collection (`roadFeatures` or `defaultFeatures`).
  2729. * - Adds the new features to the map and collection.
  2730. * - Removes old features from the map layer.
  2731. * - Updates global feature arrays and sets label color in the UI.
  2732. *
  2733. * @param {GisLayer} gisLayer - GIS layer descriptor. Should have at least: `id`, `isRoadLayer`.
  2734. * @param {Object[]} features - Array of GeoJSON Feature objects to add to the layer.
  2735. *
  2736. * @returns {void}
  2737. *
  2738. * @example
  2739. * updateGisLayerFeatures({ id: 'main', isRoadLayer: false }, [myPointFeature, myLineFeature]);
  2740. */
  2741. function updateGisLayerFeatures(gisLayer, features) {
  2742. const isRoad = gisLayer.isRoadLayer;
  2743. const layerName = isRoad ? ROAD_LAYER_NAME : DEFAULT_LAYER_NAME;
  2744. // Use the current set of features for this layer type
  2745. const sourceCollection = isRoad ? roadFeatures : defaultFeatures;
  2746.  
  2747. // Separate out features belonging to this layer vs. those not
  2748. const { featureIdsToRemove, remainingFeatures } = sourceCollection.reduce(
  2749. (acc, feature) => {
  2750. if (feature.properties.layerID === gisLayer.id) {
  2751. acc.featureIdsToRemove.push(feature.id);
  2752. } else {
  2753. acc.remainingFeatures.push(feature);
  2754. }
  2755. return acc;
  2756. },
  2757. { featureIdsToRemove: [], remainingFeatures: [] }
  2758. );
  2759.  
  2760. // Add new features to the layer
  2761. sdk.Map.dangerouslyAddFeaturesToLayerWithoutValidation({ features, layerName });
  2762.  
  2763. // Remove old features from the layer
  2764. if (featureIdsToRemove.length > 0) {
  2765. sdk.Map.removeFeaturesFromLayer({ layerName, featureIds: featureIdsToRemove });
  2766. }
  2767.  
  2768. // Update the in-memory collections
  2769. const newCollection = [...remainingFeatures, ...features];
  2770. if (isRoad) {
  2771. roadFeatures = newCollection;
  2772. } else {
  2773. defaultFeatures = newCollection;
  2774. }
  2775.  
  2776. // Feedback in UI
  2777. if (features.length) {
  2778. $(`label[for="gis-layer-${gisLayer.id}"]`).css({ color: '#00a009' });
  2779. }
  2780. }
  2781.  
  2782. /**
  2783. * Processes and adds GIS features from ArcGIS data to the appropriate map layer.
  2784. *
  2785. * - Handles ArcGIS response objects containing features and/or error.
  2786. * - Supports Point, MultiPoint, Polygon, and Polyline geometries.
  2787. * - Applies offset as configured in layer settings.
  2788. * - Assigns feature properties and labels.
  2789. * - Applies de-duplication for Points.
  2790. * - Updates in-memory/global feature collections.
  2791. * - Manages UI state/feedback for errors and successes.
  2792. * - Aborts all work if `token.cancel` is true at key moments.
  2793. *
  2794. * @param {Object} data - ArcGIS response object. Should include `.features` (Array) and/or `.error`.
  2795. * @param {Object} token - Cancellation token/object. If `token.cancel === true`, aborts processing.
  2796. * @param {GisLayer} gisLayer - GIS layer descriptor. Should have at least: `id`, `isRoadLayer`, `name`.
  2797. *
  2798. * @returns {void}
  2799. *
  2800. * @example
  2801. * // Usage:
  2802. * processFeaturesArcGIS(
  2803. * { features: [ { geometry: { x: 1, y: 2 } } ] },
  2804. * { cancel: false },
  2805. * { id: 'roads', isRoadLayer: true, name: 'Streets' }
  2806. * );
  2807. */
  2808. function processFeaturesArcGIS(data, token, gisLayer) {
  2809. const features = [];
  2810.  
  2811. if (data.skipIt) return;
  2812.  
  2813. if (data.error) {
  2814. logError(`Error in layer "${gisLayer.name}": ${data.error.message}`);
  2815. $(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
  2816. return;
  2817. }
  2818.  
  2819. const items = data.features || [];
  2820. const layerOffset = settings.getLayerSetting(gisLayer.id, 'offset') ?? { x: 0, y: 0 };
  2821. const extent = getMapExtent('wgs84');
  2822. const displayLabelsAtZoom = getGisLayerLabelsVisibleAtZoom(gisLayer, getGisLayerVisibleAtZoom(gisLayer));
  2823.  
  2824. if (!token.cancel) {
  2825. let error = false;
  2826.  
  2827. items.forEach((item) => {
  2828. if (token.cancel || error) return;
  2829. if (!item.geometry) return;
  2830.  
  2831. //---------- POINT ----------
  2832. if (item.geometry.x !== undefined && item.geometry.y !== undefined) {
  2833. let feature = turf.point([item.geometry.x, item.geometry.y]);
  2834. feature.geometry = offsetGeometry(feature.geometry, layerOffset);
  2835.  
  2836. feature = assignGisProperties(feature, gisLayer, processLabel(gisLayer, item, displayLabelsAtZoom, '', false));
  2837.  
  2838. if (isPopupVisible) addLabelToLayer(gisLayer.name, feature.properties.label);
  2839. features.push(feature);
  2840.  
  2841. //---------- MULTI-POINT ----------
  2842. } else if (item.geometry.points) {
  2843. item.geometry.points.forEach((point) => {
  2844. let feature = turf.point(point);
  2845. feature.geometry = offsetGeometry(feature.geometry, layerOffset);
  2846. feature = assignGisProperties(feature, gisLayer, processLabel(gisLayer, item, displayLabelsAtZoom, '', false));
  2847. if (isPopupVisible) addLabelToLayer(gisLayer.name, feature.properties.label);
  2848. features.push(feature);
  2849. });
  2850.  
  2851. //---------- POLYGON ----------
  2852. } else if (item.geometry.rings) {
  2853. const separatePolygons = [];
  2854. let currentOuterRing = null;
  2855. const innerRings = [];
  2856. item.geometry.rings.forEach((ringIn) => {
  2857. const ring = ringIn.map(([x, y]) => [x + layerOffset.x, y + layerOffset.y]);
  2858. if (turf.booleanClockwise(ring)) {
  2859. if (currentOuterRing) {
  2860. separatePolygons.push({ outer: currentOuterRing, inners: [...innerRings] });
  2861. }
  2862. currentOuterRing = ring;
  2863. innerRings.length = 0;
  2864. } else {
  2865. innerRings.push(ring);
  2866. }
  2867. });
  2868. if (currentOuterRing) {
  2869. separatePolygons.push({ outer: currentOuterRing, inners: [...innerRings] });
  2870. }
  2871. separatePolygons.forEach(({ outer, inners }) => {
  2872. const polygonRings = [outer, ...inners];
  2873. let feature = turf.polygon(polygonRings);
  2874. const area = turf.area(feature);
  2875.  
  2876. feature = assignGisProperties(feature, gisLayer, processLabel(gisLayer, item, displayLabelsAtZoom, area, false));
  2877. if (isPopupVisible) addLabelToLayer(gisLayer.name, feature.properties.label);
  2878. features.push(feature);
  2879. });
  2880.  
  2881. //---------- LINES / POLYLINE ----------
  2882. } else if (data.geometryType === 'esriGeometryPolyline' && item.geometry.paths) {
  2883. item.geometry.paths.forEach((path) => {
  2884. const offsetPath = path.map(([x, y]) => [x + layerOffset.x, y + layerOffset.y]);
  2885. let feature = turf.lineString(offsetPath);
  2886.  
  2887. feature = clipLineFeatureToExtent(feature, extent) || null;
  2888. if (!feature) return;
  2889.  
  2890. feature = assignGisProperties(feature, gisLayer, processLabel(gisLayer, item, displayLabelsAtZoom, '', true));
  2891. feature.skipDupeCheck = true;
  2892.  
  2893. if (isPopupVisible) addLabelToLayer(gisLayer.name, feature.properties.label);
  2894. features.push(feature);
  2895. });
  2896.  
  2897. //---------- UNKNOWN / ERROR ----------
  2898. } else {
  2899. logDebug(`Unexpected feature type in layer: ${JSON.stringify(item)}`);
  2900. logError(`Error: Unexpected feature type in layer "${gisLayer.name}"`);
  2901. $(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
  2902. error = true;
  2903. }
  2904. });
  2905. }
  2906.  
  2907. // ----- De-duplication and feature management -----
  2908. if (!token.cancel) {
  2909. // Only deduplicate if any Point features are present
  2910. if (features.some((f) => f.geometry.type === 'Point')) {
  2911. deduplicatePointFeatures(features);
  2912. }
  2913.  
  2914. // Layer/collection logic handled by helper
  2915. updateGisLayerFeatures(gisLayer, features);
  2916. }
  2917. }
  2918.  
  2919. /**
  2920. * Processes and adds features from a GeoJSON FeatureCollection or Feature array
  2921. * to the appropriate GIS map layer. Handles geometry flattening, feature offsetting,
  2922. * line clipping, label assignment, and deduplication. Updates global feature
  2923. * collections and provides UI feedback.
  2924. *
  2925. * @param {Object} data - The GeoJSON response data with a 'features' array, and possible 'error' and 'skipIt'.
  2926. * @param {Object} token - Cancellation/scoping token; if token.cancel is true, processing is aborted.
  2927. * @param {GisLayer} gisLayer - The layer descriptor object (should include at least id, name, isRoadLayer).
  2928. *
  2929. * @returns {void}
  2930. */
  2931. function processFeaturesGeoJSON(data, token, gisLayer) {
  2932. const features = [];
  2933.  
  2934. if (data.skipIt) return;
  2935.  
  2936. if (data.error) {
  2937. logError(`Error in layer "${gisLayer.name}": ${data.error.message}`);
  2938. $(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
  2939. return;
  2940. }
  2941.  
  2942. const items = data.features || [];
  2943. const layerOffset = settings.getLayerSetting(gisLayer.id, 'offset') ?? { x: 0, y: 0 };
  2944. const extent = getMapExtent('wgs84'); // [minX, minY, maxX, maxY]
  2945. const displayLabelsAtZoom = getGisLayerLabelsVisibleAtZoom(gisLayer, getGisLayerVisibleAtZoom(gisLayer));
  2946.  
  2947. if (!token.cancel) {
  2948. let error = false;
  2949.  
  2950. items.forEach((item) => {
  2951. if (token.cancel || error) return;
  2952. if (!item.geometry) return;
  2953.  
  2954. // Always GeoJSON feature. Use turf.flatten to ensure individual features.
  2955. // flatten returns a FeatureCollection, so we need to iterate over .features
  2956. // But "flatten" expects a Feature or FeatureCollection, so ensure type.
  2957. let toFlatten = item;
  2958. if (toFlatten.type !== 'Feature') {
  2959. toFlatten = {
  2960. type: 'Feature',
  2961. geometry: item.geometry,
  2962. properties: item.properties || {},
  2963. };
  2964. }
  2965. const flatFeatures = turf.flatten(toFlatten).features;
  2966.  
  2967. flatFeatures.forEach((feature) => {
  2968. // Always offset geometry!
  2969. feature.geometry = offsetGeometry(feature.geometry, layerOffset);
  2970.  
  2971. // --- CLIP LINES TO EXTENT for LineString ---
  2972. if (feature.geometry.type === 'LineString') {
  2973. feature = clipLineFeatureToExtent(feature, extent) || null;
  2974. if (!feature) return; // If fully outside, skip
  2975. }
  2976.  
  2977. // Calculate area for polygons (only needed for label)
  2978. let area = '';
  2979. if (feature.geometry.type === 'Polygon') {
  2980. area = turf.area(feature);
  2981. }
  2982.  
  2983. feature = assignGisProperties(feature, gisLayer, processLabel(gisLayer, feature, displayLabelsAtZoom, area, feature.geometry.type === 'LineString'));
  2984.  
  2985. if (isPopupVisible) addLabelToLayer(gisLayer.name, feature.properties.label);
  2986. features.push(feature);
  2987. });
  2988. });
  2989. }
  2990.  
  2991. // ----- De-duplication and feature management -----
  2992. if (!token.cancel) {
  2993. // Only deduplicate if any Point features are present
  2994. if (features.some((f) => f.geometry.type === 'Point')) {
  2995. deduplicatePointFeatures(features);
  2996. }
  2997.  
  2998. // Layer/collection logic handled by helper
  2999. updateGisLayerFeatures(gisLayer, features);
  3000. }
  3001. }
  3002.  
  3003. function copyTextToClipboard(text) {
  3004. try {
  3005. GM_setClipboard(text);
  3006. logDebug(`Copy Text To Clipboard: ${text}`);
  3007. } catch (err) {
  3008. logError(`Failed to Text To Clipboard: ${err}`);
  3009. }
  3010. }
  3011.  
  3012. function addLabelToLayer(layerName, label) {
  3013. if (!layerLabels[layerName]) {
  3014. layerLabels[layerName] = new Set();
  3015. }
  3016. layerLabels[layerName].add(label);
  3017. }
  3018.  
  3019. function replacePhrasesWithAcronyms(text) {
  3020. // Order phrases such that compound phrases come before individual words
  3021. const replacements = [
  3022. // compound phrases here
  3023. { phrase: 'Alternate Route', acronym: 'ALT' },
  3024. { phrase: 'Army Air Field', acronym: 'AAF' },
  3025. { phrase: 'County Highway', acronym: 'CH-' },
  3026. { phrase: 'County Road', acronym: 'CR-' },
  3027. { phrase: 'East Bound', acronym: 'EB' },
  3028. { phrase: 'North Bound', acronym: 'NB' },
  3029. { phrase: 'North East', acronym: 'NE' },
  3030. { phrase: 'North West', acronym: 'NW' },
  3031. { phrase: 'South Bound', acronym: 'SB' },
  3032. { phrase: 'South East', acronym: 'SE' },
  3033. { phrase: 'South West', acronym: 'SW' },
  3034. { phrase: 'State Highway', acronym: 'SH-' },
  3035. { phrase: 'State Route', acronym: 'SR-' },
  3036. { phrase: 'State Rte', acronym: 'SR-' },
  3037. { phrase: 'U.S. Highway', acronym: 'US-' },
  3038. { phrase: 'U.S. Route', acronym: 'US-' },
  3039. { phrase: 'U.S. Rte', acronym: 'US-' },
  3040. { phrase: 'U.S.Rte', acronym: 'US-' },
  3041. { phrase: 'US Highway', acronym: 'US-' },
  3042. { phrase: 'U S Highway', acronym: 'US-' },
  3043. { phrase: 'US Route', acronym: 'US-' },
  3044. { phrase: 'U S Route', acronym: 'US-' },
  3045. { phrase: 'US RTE', acronym: 'US-' },
  3046. { phrase: 'U S RTE', acronym: 'US-' },
  3047. { phrase: 'USRTE', acronym: 'US-' },
  3048. { phrase: 'West Bound', acronym: 'WB' },
  3049. // Start of single words list
  3050. { phrase: 'Alley', acronym: 'Aly' },
  3051. { phrase: 'Apartments', acronym: 'Apts' },
  3052. { phrase: 'Avenue', acronym: 'Ave' },
  3053. { phrase: 'Beach', acronym: 'Bch' },
  3054. { phrase: 'Boulevard', acronym: 'Blvd' },
  3055. { phrase: 'Bridge', acronym: 'Br' },
  3056. { phrase: 'Business', acronym: 'BUS' },
  3057. { phrase: 'Bypass', acronym: 'BYP' },
  3058. { phrase: 'Canyon', acronym: 'Cyn' },
  3059. { phrase: 'Captain', acronym: 'Capt' },
  3060. { phrase: 'Causeway', acronym: 'Cswy' },
  3061. { phrase: 'Center', acronym: 'Ctr' },
  3062. { phrase: 'Circle', acronym: 'Cir' },
  3063. { phrase: 'Colonel', acronym: 'Col.' },
  3064. { phrase: 'Commander', acronym: 'Cmdr.' },
  3065. { phrase: 'Connector', acronym: 'CONN' },
  3066. { phrase: 'Corners', acronym: 'Cors' },
  3067. { phrase: 'Corporal', acronym: 'Cpl.' },
  3068. { phrase: 'Court', acronym: 'Ct' },
  3069. { phrase: 'Cove', acronym: 'Cv' },
  3070. { phrase: 'Creek', acronym: 'Crk' },
  3071. { phrase: 'Crescent', acronym: 'Cres' },
  3072. { phrase: 'Crossing', acronym: 'X-ing' },
  3073. { phrase: 'Doctor', acronym: 'Dr.' },
  3074. { phrase: 'Drive', acronym: 'Dr' },
  3075. { phrase: 'East', acronym: 'E' },
  3076. { phrase: 'Eastbound', acronym: 'EB' },
  3077. { phrase: 'Eb', acronym: 'EB' },
  3078. { phrase: 'Express', acronym: 'EXP' },
  3079. { phrase: 'Expressway', acronym: 'Expwy' },
  3080. { phrase: 'Extension', acronym: 'Ext' },
  3081. { phrase: 'Fort', acronym: 'Ft.' },
  3082. { phrase: 'Freeway', acronym: 'Fwy' },
  3083. { phrase: 'General', acronym: 'Gen.' },
  3084. { phrase: 'Governor', acronym: 'Gov.' },
  3085. { phrase: 'Grove', acronym: 'Grv' },
  3086. { phrase: 'Heights', acronym: 'Hts' },
  3087. { phrase: 'Highway', acronym: 'Hwy' },
  3088. { phrase: 'Honerable', acronym: 'Hon.' },
  3089. { phrase: 'International', acronym: 'Intl' },
  3090. { phrase: 'Interstate', acronym: 'I-' },
  3091. { phrase: 'Junior', acronym: 'Jr.' },
  3092. { phrase: 'Landing', acronym: 'Lndg' },
  3093. { phrase: 'Lane', acronym: 'Ln' },
  3094. { phrase: 'Lieutenant', acronym: 'Lt.' },
  3095. { phrase: 'Loop', acronym: 'Lp' },
  3096. { phrase: 'Major', acronym: 'Maj.' },
  3097. { phrase: 'Manor', acronym: 'Mnr.' },
  3098. { phrase: 'Meadow', acronym: 'Mdw' },
  3099. { phrase: 'Mount', acronym: 'Mt' },
  3100. { phrase: 'Mountain', acronym: 'Mtn' },
  3101. { phrase: 'Mountains', acronym: 'Mtns' },
  3102. { phrase: 'National', acronym: "Nat'l" },
  3103. { phrase: 'North', acronym: 'N' },
  3104. { phrase: 'Northbound', acronym: 'NB' },
  3105. { phrase: 'Nb', acronym: 'NB' },
  3106. { phrase: 'Northeast', acronym: 'NE' },
  3107. { phrase: 'Northwest', acronym: 'NW' },
  3108. { phrase: 'Park', acronym: 'Pk' },
  3109. { phrase: 'Parkway', acronym: 'Pkwy' },
  3110. { phrase: 'Parkways', acronym: 'Pkwys' },
  3111. { phrase: 'Passage', acronym: 'Psge' },
  3112. { phrase: 'Place', acronym: 'Pl' },
  3113. { phrase: 'Plaza', acronym: 'Plz' },
  3114. { phrase: 'Point', acronym: 'Pt' },
  3115. { phrase: 'Points', acronym: 'Pts' },
  3116. { phrase: 'President', acronym: 'Pres.' },
  3117. { phrase: 'Professor', acronym: 'Prof.' },
  3118. { phrase: 'Railroad', acronym: 'R.R.' },
  3119. { phrase: 'Road', acronym: 'Rd' },
  3120. { phrase: 'Recreational', acronym: 'Rec.' },
  3121. { phrase: 'Reverend', acronym: 'Rev.' },
  3122. { phrase: 'Route', acronym: 'SR-' },
  3123. { phrase: 'Saint', acronym: 'St.' },
  3124. { phrase: 'Sainte', acronym: 'Ste.' },
  3125. { phrase: 'Senior', acronym: 'Sr.' },
  3126. { phrase: 'Sergeant', acronym: 'Sgt.' },
  3127. { phrase: 'Skyway', acronym: 'Skwy' },
  3128. { phrase: 'South', acronym: 'S' },
  3129. { phrase: 'Southbound', acronym: 'SB' },
  3130. { phrase: 'Sb', acronym: 'SB' },
  3131. { phrase: 'Southeast', acronym: 'SE' },
  3132. { phrase: 'Southwest', acronym: 'SW' },
  3133. { phrase: 'Springs', acronym: 'Spgs' },
  3134. { phrase: 'Square', acronym: 'Sq' },
  3135. { phrase: 'Station', acronym: 'Sta' },
  3136. { phrase: 'Street', acronym: 'St' },
  3137. { phrase: 'Terrace', acronym: 'Ter' },
  3138. { phrase: 'Throughway', acronym: 'Thwy' },
  3139. { phrase: 'Thruway', acronym: 'Thwy' },
  3140. { phrase: 'Tollway', acronym: 'Tlwy' },
  3141. { phrase: 'Township', acronym: 'Twp' },
  3142. { phrase: 'Trafficway', acronym: 'Trfy' },
  3143. { phrase: 'Trail', acronym: 'Trl' },
  3144. { phrase: 'Tunnel', acronym: 'Tun' },
  3145. { phrase: 'Turnpike', acronym: 'Tpk' },
  3146. { phrase: 'Upper', acronym: 'Upr' },
  3147. { phrase: 'U.S.', acronym: 'US' },
  3148. { phrase: 'Valley', acronym: 'Vly' },
  3149. { phrase: 'West', acronym: 'W' },
  3150. { phrase: 'Westbound', acronym: 'WB' },
  3151. { phrase: 'Wb', acronym: 'WB' },
  3152. { phrase: '--', acronym: '-' },
  3153. { phrase: ' -', acronym: '-' },
  3154. { phrase: '- ', acronym: '-' },
  3155. { phrase: '- -', acronym: '-' },
  3156. ];
  3157.  
  3158. let updatedText = text;
  3159.  
  3160. // Replace phrases with their acronyms, case insensitive
  3161. replacements.forEach(({ phrase, acronym }) => {
  3162. const regex = new RegExp(`\\b${phrase}\\b`, 'gi'); // Uses \\b to match words with word boundaries
  3163. updatedText = updatedText.replace(regex, acronym);
  3164. });
  3165.  
  3166. return updatedText;
  3167. }
  3168.  
  3169. function fixSateHwyRoadNames(text) {
  3170. // Regular expression to capture patterns like "XXX ###", "XXX-###", "XXX###", as well as "Us Route #", "Us Rte #", and "Route #", "Rte #"
  3171. const regex = /(?:([A-Z]{1,3})[-\s]?(\d{1,3})|(?:Us\s+(?:Rte|Route)\s+(\d{1,3}))|(?:Rte[-\s]?(\d{1,3})|Route\s+(\d{1,3})))\b/gi;
  3172.  
  3173. // Replacement function formats matched patterns
  3174. return text.replace(regex, (match, letters, numbers, usRouteNumber, rteNumber, routeNumber) => {
  3175. if (usRouteNumber) {
  3176. return `US-${usRouteNumber}`; // for "US Route"/s
  3177. }
  3178. if (rteNumber || routeNumber) {
  3179. return `SR-${rteNumber || routeNumber}`; // Change "Rte" or "Route" to "SR"
  3180. }
  3181. if (letters && numbers) {
  3182. return `${letters.toUpperCase()}-${numbers}`; // General format for other letter-number combos
  3183. }
  3184. return match;
  3185. });
  3186. }
  3187.  
  3188. function titleCaseLabel(text) {
  3189. // Read each line separately
  3190. const lines = text.split('\n');
  3191. return lines
  3192. .map((line) => {
  3193. const trimmedLine = line.trim(); // Trim the line to remove leading/trailing spaces
  3194. const words = trimmedLine.split(' '); // Split the line into individual words
  3195. // Capitalize the first letter of each word and convert the rest to lowercase
  3196. const titleCasedLine = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' '); // Recombine the words into a title-cased line
  3197. return titleCasedLine; // Return the formatted line
  3198. })
  3199. .join('\n'); // Combine all the lines back into a single string separated by new lines
  3200. }
  3201.  
  3202. function processedLabel(label) {
  3203. if (useTitleCase) {
  3204. label = titleCaseLabel(label);
  3205. }
  3206. if (useAcronyms) {
  3207. label = replacePhrasesWithAcronyms(label);
  3208. }
  3209. if (useStateHwy) {
  3210. label = fixSateHwyRoadNames(label);
  3211. }
  3212. if (removeNewLines) {
  3213. label = label.replace(/[\r\n]+/g, ' ');
  3214. }
  3215. return label;
  3216. }
  3217.  
  3218. function updatePopup(labels) {
  3219. let popup = document.getElementById('layerLabelPopup');
  3220. if (!popup) {
  3221. popup = document.createElement('div');
  3222. popup.id = 'layerLabelPopup';
  3223. popup.style = `position: absolute; background: #d3d3d3; border: 2px solid #007bff; border-radius: 5px;
  3224. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); z-index: 1000; width: 500px; max-width: 800px;
  3225. height: 300px; resize: both; overflow: hidden; max-height: 700px; left: ${popupPosition.left}; top: ${popupPosition.top}; `;
  3226. const header = document.createElement('div');
  3227. header.style = 'background: #007bff; color: #fff; padding: 5px; cursor: move; border-radius: 3px 3px 0 0; display: flex; justify-content: space-between; align-items: center; height: 30px; ';
  3228.  
  3229. const title = document.createElement('span');
  3230. title.innerText = 'GIS-L Layer Labels';
  3231. header.appendChild(title);
  3232.  
  3233. const closeButton = document.createElement('span');
  3234. closeButton.innerText = '×';
  3235. closeButton.style = 'cursor: pointer; font-size: 20px; margin-left: 10px; ';
  3236. closeButton.addEventListener('click', () => {
  3237. isPopupVisible = false;
  3238. togglePopupVisibility();
  3239. $('input[name="popupVisibility"][value="show"]').prop('checked', isPopupVisible);
  3240. $('input[name="popupVisibility"][value="hide"]').prop('checked', !isPopupVisible);
  3241. saveSettingsToStorage();
  3242. });
  3243. header.appendChild(closeButton);
  3244. popup.appendChild(header);
  3245.  
  3246. const formatOptionContainer = document.createElement('div');
  3247. formatOptionContainer.style = 'background: #72767d; color: #fff;';
  3248.  
  3249. const firstRow = document.createElement('div');
  3250. firstRow.style = 'display: flex; gap: 10px; align-items: flex-start; justify-content: flex-start;';
  3251.  
  3252. const formatCheckbox = document.createElement('input');
  3253. formatCheckbox.type = 'checkbox';
  3254. formatCheckbox.id = 'useTitleCaseCheckbox';
  3255. formatCheckbox.style = 'margin-left: 10px';
  3256. formatCheckbox.checked = useTitleCase;
  3257. formatCheckbox.addEventListener('change', () => {
  3258. useTitleCase = formatCheckbox.checked;
  3259. updatePopupContent(labels);
  3260. saveSettingsToStorage();
  3261. });
  3262. firstRow.appendChild(formatCheckbox);
  3263.  
  3264. const formatCheckboxLabel = document.createElement('label');
  3265. formatCheckboxLabel.htmlFor = 'useTitleCaseCheckbox';
  3266. formatCheckboxLabel.innerText = 'Use Title Case';
  3267. formatCheckboxLabel.style = 'font-weight: 100; width: 150px;';
  3268. firstRow.appendChild(formatCheckboxLabel);
  3269.  
  3270. const acronymCheckbox = document.createElement('input');
  3271. acronymCheckbox.type = 'checkbox';
  3272. acronymCheckbox.id = 'useacronymsCheckbox';
  3273. acronymCheckbox.checked = useAcronyms;
  3274. acronymCheckbox.addEventListener('change', () => {
  3275. useAcronyms = acronymCheckbox.checked;
  3276. updatePopupContent(labels);
  3277. saveSettingsToStorage();
  3278. });
  3279. firstRow.appendChild(acronymCheckbox);
  3280.  
  3281. const acronymCheckboxLabel = document.createElement('label');
  3282. acronymCheckboxLabel.htmlFor = 'useacronymsCheckbox';
  3283. acronymCheckboxLabel.innerText = 'Use Acronyms & Abbreviations';
  3284. acronymCheckboxLabel.style = 'font-weight: 100;';
  3285. firstRow.appendChild(acronymCheckboxLabel);
  3286. formatOptionContainer.appendChild(firstRow);
  3287.  
  3288. const secondRow = document.createElement('div');
  3289. secondRow.style = 'display: flex; gap: 10px; align-items: flex-start; justify-content: flex-start;';
  3290.  
  3291. const stateHwyCheckbox = document.createElement('input');
  3292. stateHwyCheckbox.type = 'checkbox';
  3293. stateHwyCheckbox.id = 'useStateHwyCheckbox';
  3294. stateHwyCheckbox.style = 'margin-left: 10px';
  3295. stateHwyCheckbox.checked = useStateHwy;
  3296. stateHwyCheckbox.addEventListener('change', () => {
  3297. useStateHwy = stateHwyCheckbox.checked;
  3298. updatePopupContent(labels);
  3299. saveSettingsToStorage();
  3300. });
  3301. secondRow.appendChild(stateHwyCheckbox);
  3302.  
  3303. const stateHwyCheckboxLabel = document.createElement('label');
  3304. stateHwyCheckboxLabel.htmlFor = 'useStateHwyCheckbox';
  3305. stateHwyCheckboxLabel.innerText = 'Fix Highway Labels';
  3306.  
  3307. stateHwyCheckboxLabel.style = 'font-weight: 100; width: 150px;';
  3308. secondRow.appendChild(stateHwyCheckboxLabel);
  3309.  
  3310. const removeNewLinesCheckbox = document.createElement('input');
  3311. removeNewLinesCheckbox.type = 'checkbox';
  3312. removeNewLinesCheckbox.id = 'removeNewLinesCheckbox';
  3313. removeNewLinesCheckbox.checked = removeNewLines;
  3314. removeNewLinesCheckbox.addEventListener('change', () => {
  3315. removeNewLines = removeNewLinesCheckbox.checked;
  3316. updatePopupContent(labels);
  3317. saveSettingsToStorage();
  3318. });
  3319. secondRow.appendChild(removeNewLinesCheckbox);
  3320.  
  3321. const removeNewLinesCheckboxLabel = document.createElement('label');
  3322. removeNewLinesCheckboxLabel.htmlFor = 'removeNewLinesCheckbox';
  3323. removeNewLinesCheckboxLabel.innerText = 'Remove New Lines';
  3324. removeNewLinesCheckboxLabel.style = 'font-weight: 100;';
  3325. secondRow.appendChild(removeNewLinesCheckboxLabel);
  3326.  
  3327. formatOptionContainer.appendChild(secondRow);
  3328. popup.appendChild(formatOptionContainer);
  3329.  
  3330. const dropdownContainer = document.createElement('div');
  3331. dropdownContainer.style = 'margin-bottom: 10px;';
  3332. popup.appendChild(dropdownContainer);
  3333.  
  3334. const contentContainer = document.createElement('div');
  3335. contentContainer.style = 'padding: 5px; overflow-y: auto; overflow-x: auto; height: calc(100% - 110px);';
  3336.  
  3337. popup.appendChild(contentContainer);
  3338.  
  3339. const mapElement = document.getElementsByTagName('wz-page-content')[0];
  3340. if (mapElement) {
  3341. mapElement.appendChild(popup);
  3342. }
  3343.  
  3344. header.onmousedown = function (event) {
  3345. event.preventDefault();
  3346. const parentRect = mapElement.getBoundingClientRect();
  3347. const initialX = event.clientX;
  3348. const initialY = event.clientY;
  3349. const offsetX = initialX - parentRect.left - popup.offsetLeft;
  3350. const offsetY = initialY - parentRect.top - popup.offsetTop;
  3351.  
  3352. document.onmousemove = function (ev) {
  3353. popup.style.left = `${ev.clientX - offsetX - parentRect.left}px`;
  3354. popup.style.top = `${ev.clientY - offsetY - parentRect.top}px`;
  3355.  
  3356. popupPosition.left = popup.style.left;
  3357. popupPosition.top = popup.style.top;
  3358. };
  3359.  
  3360. document.onmouseup = function () {
  3361. document.onmousemove = null;
  3362. document.onmouseup = null;
  3363. };
  3364. };
  3365. }
  3366.  
  3367. updatePopupContent(labels);
  3368. popup.style.display = isPopupVisible ? 'block' : 'none';
  3369. }
  3370.  
  3371. function updatePopupContent(labels) {
  3372. const dropdownContainer = document.querySelector('#layerLabelPopup div:nth-child(3)');
  3373. const contentContainer = document.querySelector('#layerLabelPopup div:nth-child(4)');
  3374.  
  3375. dropdownContainer.innerHTML = '';
  3376. contentContainer.innerHTML = '';
  3377.  
  3378. const select = document.createElement('select');
  3379. select.style = 'width: 100%; padding: 5px; border: 1px solid #ccc;';
  3380.  
  3381. const sortedLayerNames = Object.keys(labels).sort();
  3382. sortedLayerNames.forEach((layerName) => {
  3383. const option = document.createElement('option');
  3384. option.value = layerName;
  3385. option.innerText = layerName;
  3386. select.appendChild(option);
  3387.  
  3388. const uniqueLabels = Array.from(labels[layerName]).sort();
  3389. const tabContent = document.createElement('div');
  3390. tabContent.style = 'display: none; width: 100%; white-space: pre;';
  3391.  
  3392. const processedLabels = uniqueLabels
  3393. .map((label) => {
  3394. const text = processedLabel(label);
  3395. const copyIcon = '<span style="cursor: pointer; margin-left: 5px;" title="Copy to clipboard">📋</span>';
  3396. return `<li style="margin-bottom: 0.3em; color: #000000;" data-label="${text}">${text}${copyIcon}</li>`;
  3397. })
  3398. .join('');
  3399.  
  3400. tabContent.innerHTML = `<ul style="padding-left: 20px; margin-top: 0;">${processedLabels}</ul>`;
  3401. contentContainer.appendChild(tabContent);
  3402.  
  3403. // Add copying functionality
  3404. tabContent.querySelectorAll('li').forEach((li) => {
  3405. const icon = li.querySelector('span');
  3406. if (icon) {
  3407. icon.addEventListener('click', () => {
  3408. const textToCopy = li.getAttribute('data-label'); // Get the text from a custom data attribute
  3409. copyTextToClipboard(textToCopy);
  3410. });
  3411. }
  3412. });
  3413. });
  3414.  
  3415. dropdownContainer.appendChild(select);
  3416.  
  3417. let selectedLayerIndex = sortedLayerNames.indexOf(popupActiveLayer);
  3418.  
  3419. if (selectedLayerIndex === -1 && select.options.length > 0) {
  3420. selectedLayerIndex = 0;
  3421. popupActiveLayer = sortedLayerNames[selectedLayerIndex];
  3422. }
  3423. select.selectedIndex = selectedLayerIndex;
  3424.  
  3425. const allContents = contentContainer.querySelectorAll('div');
  3426. allContents.forEach((content, index) => {
  3427. content.style.display = index === select.selectedIndex ? 'block' : 'none';
  3428. });
  3429.  
  3430. select.addEventListener('change', () => {
  3431. const contents = contentContainer.querySelectorAll('div');
  3432. contents.forEach((content, index) => {
  3433. content.style.display = index === select.selectedIndex ? 'block' : 'none';
  3434. });
  3435. popupActiveLayer = select.value;
  3436. });
  3437. }
  3438.  
  3439. /**
  3440. * Asynchronously fetches GIS features for visible, user-selected map layers, based on current viewport and settings.
  3441. *
  3442. * Functionality:
  3443. * - Clears existing feature labels if a popup is visible, then returns early if fetching is disabled or zoom is below threshold.
  3444. * - Determines which map layers are both fetchable and visible, and removes features for layers not being fetched.
  3445. * - Updates layer checkbox UI and logs intended fetch actions.
  3446. * - For each eligible GIS layer:
  3447. * - Assembles an HTTP GET request (supports ArcGIS and Socrata platforms).
  3448. * - Handles required API tokens and warns about missing tokens for relevant platforms.
  3449. * - On successful response, delegates to the right feature processing function,
  3450. * updates features, tracks per-layer processing, and updates the popup if needed.
  3451. * - Logs and handles errors robustly (parsing, HTTP, platform, etc), including explicit UI feedback.
  3452. *
  3453. * Notes:
  3454. * - The function leverages global application state for layers, map zoom, in-memory features, and UI feedback.
  3455. * - Relies on helper functions and several external APIs (e.g., `sdk.Map`, `GM_xmlhttpRequest`, jQuery).
  3456. * - Non-blocking: each layer fetch is asynchronous and processed independently.
  3457. *
  3458. * Error Handling:
  3459. * - Logs parsing and HTTP errors with details.
  3460. * - Sets UI labels to red for layers with errors or parsing issues.
  3461. * - Alerts user if required API tokens are missing.
  3462. *
  3463. * Side Effects:
  3464. * - Updates global feature collections (e.g., `roadFeatures`, `defaultFeatures`), label maps, popup contents, and UI highlighting.
  3465. *
  3466. * @async
  3467. * @returns {Promise<void>} Does not resolve to a value. Operates via side effects on global state, the map, and the UI.
  3468. *
  3469. * @example
  3470. * // Usually called without parameters, in response to map move/zoom or UI change:
  3471. * await fetchFeatures();
  3472. */
  3473. async function fetchFeatures() {
  3474. // 1. Clear labels if popup is open
  3475. if (isPopupVisible) {
  3476. Object.keys(layerLabels).forEach((key) => delete layerLabels[key]);
  3477. }
  3478. if (ignoreFetch) return;
  3479. if (sdk.Map.getZoomLevel() < 12) return;
  3480.  
  3481. await whatsInView();
  3482.  
  3483. lastToken.cancel = true;
  3484. lastToken = { cancel: false, features: [], layersProcessed: 0 };
  3485. $('.gis-subL1-layer-label').css({});
  3486.  
  3487. let _layersCleared = false;
  3488. let layersToFetch = [];
  3489.  
  3490. // 2. Prepare and clear features for layers not being fetched
  3491. if (!_layersCleared) {
  3492. _layersCleared = true;
  3493. layersToFetch = getFetchableLayers(true, true);
  3494. _gisLayers.forEach((gisLayer) => {
  3495. if (!layersToFetch.includes(gisLayer)) {
  3496. let featureCollection = gisLayer.isRoadLayer ? roadFeatures : defaultFeatures;
  3497. const layerName = gisLayer.isRoadLayer ? ROAD_LAYER_NAME : DEFAULT_LAYER_NAME;
  3498. const featureIds = featureCollection.filter((f) => f.properties.layerID === gisLayer.id).map((f) => f.id);
  3499. if (featureIds.length) {
  3500. sdk.Map.removeFeaturesFromLayer({ layerName, featureIds });
  3501. featureCollection = featureCollection.filter((f) => !featureIds.includes(f.id));
  3502. if (gisLayer.isRoadLayer) {
  3503. roadFeatures = featureCollection;
  3504. } else {
  3505. defaultFeatures = featureCollection;
  3506. }
  3507. }
  3508. }
  3509. });
  3510. }
  3511.  
  3512. filterLayerCheckboxes();
  3513. logDebug(`Fetching ${layersToFetch.length} layers...`, layersToFetch);
  3514.  
  3515. let layersProcessedCount = 0;
  3516. const extentWGS84 = getMapExtent('wgs84');
  3517. const zoom = sdk.Map.getZoomLevel();
  3518.  
  3519. // 3. Fetch features per-layer
  3520. layersToFetch.forEach((gisLayer) => {
  3521. const url = getUrl(extentWGS84, gisLayer, zoom);
  3522.  
  3523. // Build headers if needed
  3524. /** @type {Object.<string, string>} */
  3525. const headers = {};
  3526. const appToken = settings.socrataAppToken ? settings.socrataAppToken.trim() : '';
  3527. const isSocrata = gisLayer.platform === 'SocrataV2' || gisLayer.platform === 'SocrataV3';
  3528.  
  3529. if (isSocrata && appToken) {
  3530. headers['X-App-Token'] = appToken;
  3531. }
  3532. if (gisLayer.platform === 'SocrataV3' && !appToken) {
  3533. logDebug(`Socrata V3 layer "${gisLayer.id}" requires an App Token, but none was provided.`);
  3534. WazeWrap.Alerts.warning(GM_info.script.name, `A Socrata App Token is required for layer "${gisLayer.name}".<br>Please provide one in the GIS Layers settings.`);
  3535. return;
  3536. }
  3537.  
  3538. GM_xmlhttpRequest({
  3539. url,
  3540. headers,
  3541. context: lastToken,
  3542. method: 'GET',
  3543. onload(res2) {
  3544. if (res2.status < 400) {
  3545. try {
  3546. const parsedData = $.parseJSON(res2.responseText);
  3547.  
  3548. // Call appropriate feature processor
  3549. if (gisLayer.platform === 'ArcGIS' || !gisLayer.platform) {
  3550. processFeaturesArcGIS(parsedData, res2.context, gisLayer);
  3551. } else if (isSocrata) {
  3552. processFeaturesGeoJSON(parsedData, res2.context, gisLayer);
  3553. } else {
  3554. logError(`Unknown platform "${gisLayer.platform}" for layer "${gisLayer.id}". Skipped processing.`);
  3555. }
  3556. } catch (parseError) {
  3557. logError(`Parsing error for layer "${gisLayer.id}": ${parseError.message}`);
  3558. $(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
  3559. }
  3560. layersProcessedCount += 1;
  3561. if (layersProcessedCount === layersToFetch.length && isPopupVisible) {
  3562. updatePopup(layerLabels);
  3563. }
  3564. } else {
  3565. logError(`HTTP error for layer "${gisLayer.id}": ${res2.status} ${res2.statusText}`);
  3566. $(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
  3567. }
  3568. },
  3569. onerror(res3) {
  3570. logError(`Could not fetch layer "${gisLayer.id}". Error: ${res3.statusText} (status code: ${res3.status})`);
  3571. $(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
  3572. },
  3573. });
  3574. });
  3575. }
  3576.  
  3577. function showScriptInfoAlert() {
  3578. /* Check version and alert on update */
  3579. if (SHOW_UPDATE_MESSAGE && scriptVersion !== settings.lastVersion) {
  3580. // alert(SCRIPT_VERSION_CHANGES);
  3581. let releaseNotes = '';
  3582. releaseNotes += "<p>What's New:</p>";
  3583. if (SCRIPT_VERSION_CHANGES.length > 0) {
  3584. releaseNotes += '<ul>';
  3585. for (let idx = 0; idx < SCRIPT_VERSION_CHANGES.length; idx++) releaseNotes += `<li>${SCRIPT_VERSION_CHANGES[idx]}`;
  3586. releaseNotes += '</ul>';
  3587. } else {
  3588. releaseNotes += '<ul><li>Nothing major.</ul>';
  3589. }
  3590. WazeWrap.Interface.ShowScriptUpdate(GM_info.script.name, scriptVersion, releaseNotes, GF_URL);
  3591. }
  3592. }
  3593.  
  3594. async function setEnabled(value) {
  3595. settings.enabled = value;
  3596. saveSettingsToStorage();
  3597. sdk.Map.setLayerVisibility({ layerName: DEFAULT_LAYER_NAME, visibility: value });
  3598. sdk.Map.setLayerVisibility({ layerName: ROAD_LAYER_NAME, visibility: value });
  3599. const color = value ? '#00bd00' : '#ccc';
  3600. $('span#gis-layers-power-btn').css({ color });
  3601. if (value) await fetchFeatures();
  3602. sdk.LayerSwitcher.setLayerCheckboxChecked({ name: 'GIS Layers', isChecked: value });
  3603.  
  3604. // Show/hide the popup based on the enabled state
  3605. const popup = document.getElementById('layerLabelPopup');
  3606. if (popup) {
  3607. popup.style.display = value ? 'block' : 'none';
  3608. isPopupVisible = value;
  3609. }
  3610. }
  3611.  
  3612. async function onGisLayerToggleChanged() {
  3613. const checked = $(this).is(':checked');
  3614. const layerId = $(this).data('layer-id');
  3615. const idx = settings.visibleLayers.indexOf(layerId);
  3616. if (checked) {
  3617. const gisLayer = _gisLayers.find((l) => l.id === layerId);
  3618. if (gisLayer.oneTimeAlert) {
  3619. const lastAlertHash = settings.oneTimeAlerts[layerId];
  3620. const newAlertHash = hashString(gisLayer.oneTimeAlert);
  3621. if (lastAlertHash !== newAlertHash) {
  3622. // alert(`Layer: ${gisLayer.name}\n\nMessage:\n${gisLayer.oneTimeAlert}`);
  3623. WazeWrap.Alerts.info(GM_info.script.name, `Layer: ${gisLayer.name}<br><br>Message:<br>${gisLayer.oneTimeAlert}`);
  3624. settings.oneTimeAlerts[layerId] = newAlertHash;
  3625. saveSettingsToStorage();
  3626. }
  3627. }
  3628. if (idx === -1) settings.visibleLayers.push(layerId);
  3629. } else if (idx > -1) settings.visibleLayers.splice(idx, 1);
  3630. if (!ignoreFetch) {
  3631. saveSettingsToStorage();
  3632. await fetchFeatures();
  3633. }
  3634. }
  3635.  
  3636. async function onOnlyShowApplicableLayersChanged() {
  3637. settings.onlyShowApplicableLayers = $(this).is(':checked');
  3638. saveSettingsToStorage();
  3639. filterLayerCheckboxes();
  3640. }
  3641.  
  3642. async function onOnlyShowApplicableLayersZoomChanged() {
  3643. settings.onlyShowApplicableLayersZoom = $(this).is(':checked');
  3644. saveSettingsToStorage();
  3645. filterLayerCheckboxes();
  3646. }
  3647.  
  3648. async function onSub1CheckChanged(subL1, evt) {
  3649. const idx = settings.selectedSubL1.indexOf(subL1);
  3650. if (evt.target.checked) {
  3651. if (idx === -1) settings.selectedSubL1.push(subL1);
  3652. } else if (idx > -1) settings.selectedSubL1.splice(idx, 1);
  3653. if (!ignoreFetch) {
  3654. saveSettingsToStorage();
  3655. initLayersTab();
  3656. await fetchFeatures();
  3657. }
  3658. }
  3659.  
  3660. async function batchUpdateSelectedSubL1() {
  3661. // Gather all checked subL1's from DOM
  3662. const checked = $('.gis-layers-subL1-checkbox:checked')
  3663. .map(function () {
  3664. return $(this).data('sub');
  3665. })
  3666. .get();
  3667.  
  3668. settings.selectedSubL1 = checked;
  3669.  
  3670. if (!ignoreFetch) {
  3671. saveSettingsToStorage();
  3672. initLayersTab();
  3673. await fetchFeatures();
  3674. }
  3675. }
  3676.  
  3677. function onLayerCheckboxChanged(args) {
  3678. setEnabled(args.checked);
  3679. }
  3680.  
  3681. function setFillParcels(doFill) {
  3682. [LAYER_STYLES.parcels, LAYER_STYLES.state_parcels].forEach((style) => {
  3683. style.fillOpacity = doFill ? 0.2 : 0;
  3684. });
  3685. }
  3686.  
  3687. async function onFillParcelsCheckedChanged(evt) {
  3688. const { checked } = evt.target;
  3689. setFillParcels(checked);
  3690. settings.fillParcels = checked;
  3691. saveSettingsToStorage();
  3692. await fetchFeatures();
  3693. }
  3694.  
  3695. async function onMapMove() {
  3696. if (settings.enabled) {
  3697. await loadVisibleCountryData();
  3698. await fetchFeatures();
  3699. }
  3700. }
  3701.  
  3702. function onRefreshLayersClick() {
  3703. const $btn = $('#gis-layers-refresh');
  3704. if (!$btn.hasClass('fa-spin')) {
  3705. $btn.css({ cursor: 'auto' });
  3706. $btn.addClass('fa-spin');
  3707. init(false);
  3708. }
  3709. }
  3710.  
  3711. function onChevronClick(evt) {
  3712. const $target = $(evt.currentTarget);
  3713. const $div = $($target.siblings()[0]);
  3714. const fieldsetId = $target.parent('fieldset').attr('id');
  3715. const sectionKey = fieldsetId ? fieldsetId.replace('gis-layers-for-', '') : null;
  3716. $($target.children()[0]).toggleClass('fa fa-fw fa-chevron-down').toggleClass('fa fa-fw fa-chevron-right');
  3717. if ($div.css('display') === 'none') {
  3718. $div.css('display', 'block');
  3719. if (sectionKey) settings.collapsedSections[sectionKey] = false;
  3720. } else {
  3721. $div.css('display', 'none');
  3722. if (sectionKey) settings.collapsedSections[sectionKey] = true;
  3723. }
  3724. if (sectionKey) saveSettingsToStorage();
  3725. }
  3726.  
  3727. async function doToggleABunch(evt, checkState) {
  3728. ignoreFetch = true;
  3729. $(evt.target).closest('fieldset').find('input').prop('checked', !checkState).trigger('click');
  3730. ignoreFetch = false;
  3731. saveSettingsToStorage();
  3732. if (evt.data) initLayersTab();
  3733. await fetchFeatures();
  3734. }
  3735.  
  3736. function onSelectAllClick(evt) {
  3737. doToggleABunch(evt, true);
  3738. }
  3739.  
  3740. function onSelectNoneClick(evt) {
  3741. doToggleABunch(evt, false);
  3742. }
  3743.  
  3744. async function onGisAddrDisplayChange(evt) {
  3745. settings.addrLabelDisplay = evt.target.value;
  3746. saveSettingsToStorage();
  3747. await fetchFeatures();
  3748. }
  3749.  
  3750. function onAddressDisplayShortcutKey() {
  3751. if (!$('#gisAddrDisplay-hn').is(':checked')) {
  3752. $('#gisAddrDisplay-hn').click();
  3753. } else {
  3754. $('#gisAddrDisplay-all').click();
  3755. }
  3756. }
  3757.  
  3758. function onToggleGisLayersShortcutKey() {
  3759. setEnabled(!settings.enabled);
  3760. }
  3761.  
  3762. function togglePopupVisibility() {
  3763. const popup = document.getElementById('layerLabelPopup');
  3764. if (popup) {
  3765. popup.style.display = isPopupVisible ? 'block' : 'none';
  3766. }
  3767. saveSettingsToStorage();
  3768. }
  3769.  
  3770. /**
  3771. * Initializes and configures GIS map layers on the map SDK.
  3772. *
  3773. * This function:
  3774. * - Generates style rules for each GIS layer (excluding those with 'roads' style),
  3775. * - Sets parcel fill visualization based on settings,
  3776. * - Removes then adds map layers with appropriate styling and label contexts,
  3777. * - Sets layer visibility according to current settings.
  3778. *
  3779. * Dependencies and required globals:
  3780. * - _gisLayers: Array of GIS layer objects ({ id, style, ... })
  3781. * - LAYER_STYLES: Object containing available layer styles
  3782. * - settings: Layer-related user/application settings ({ fillParcels, enabled })
  3783. * - sdk: WME SDK object
  3784. * - DEFAULT_LAYER_NAME, ROAD_LAYER_NAME: String constants for layer names
  3785. * - DEFAULT_STYLE, ROAD_STYLE: Style objects for layers
  3786. * - setFillParcels: Function to update parcel visualization
  3787. *
  3788. * Side Effects:
  3789. * - Modifies visible layers on the map via sdk.Map
  3790. * - May throw or suppress errors depending on layer state
  3791. *
  3792. * @function initLayer
  3793. */
  3794. function initLayer() {
  3795. const rules = _gisLayers
  3796. .filter((gisLayer) => gisLayer.style && gisLayer.style !== 'roads')
  3797. .map((gisLayer) => {
  3798. let style;
  3799. if (LAYER_STYLES.hasOwnProperty(gisLayer.style)) {
  3800. style = LAYER_STYLES[gisLayer.style];
  3801. } else {
  3802. style = gisLayer.style;
  3803. }
  3804. return {
  3805. predicate: (featureProperties) => featureProperties.layerID === gisLayer.id,
  3806. style,
  3807. };
  3808. });
  3809.  
  3810. setFillParcels(settings.fillParcels);
  3811.  
  3812. try {
  3813. sdk.Map.removeLayer({ layerName: DEFAULT_LAYER_NAME });
  3814. } catch (e) {
  3815. // If InvalidStateError, then the layer doesn't exist yet. Ignore the error
  3816. if (!(e instanceof sdk.Errors.InvalidStateError)) {
  3817. throw e;
  3818. }
  3819. }
  3820. sdk.Map.addLayer({
  3821. layerName: DEFAULT_LAYER_NAME,
  3822. styleContext: {
  3823. getLabel: (context) => context.feature?.properties?.label,
  3824. },
  3825. styleRules: [{ style: DEFAULT_STYLE }, ...rules],
  3826. zIndexing: true,
  3827. });
  3828.  
  3829. try {
  3830. sdk.Map.removeLayer({ layerName: ROAD_LAYER_NAME });
  3831. } catch (e) {
  3832. // If InvalidStateError, then the layer doesn't exist yet. Ignore the error
  3833. if (!(e instanceof sdk.Errors.InvalidStateError)) {
  3834. throw e;
  3835. }
  3836. }
  3837. const zoomLevel = sdk.Map.getZoomLevel();
  3838. sdk.Map.addLayer({
  3839. layerName: ROAD_LAYER_NAME,
  3840. styleContext: {
  3841. getLabel: (context) => context.feature?.properties?.label,
  3842. getOffset: () => -(zoomLevel + 5),
  3843. getSmooth: () => '',
  3844. getReadable: () => '1',
  3845. },
  3846. styleRules: [{ style: ROAD_STYLE }],
  3847. });
  3848.  
  3849. sdk.Map.setLayerVisibility({ layerName: DEFAULT_LAYER_NAME, visibility: settings?.enabled });
  3850. sdk.Map.setLayerVisibility({ layerName: ROAD_LAYER_NAME, visibility: settings?.enabled });
  3851. } // END InitLayer
  3852.  
  3853. /**
  3854. * Initializes and renders the GIS Layers tab user interface.
  3855. *
  3856. * This function rebuilds the '#panel-gis-subL1-layers' container DOM,
  3857. * including checkboxes and controls for filtering layers by region, zoom level,
  3858. * and specific SubL1 categories. It binds all relevant event handlers for interactions.
  3859. *
  3860. * Dependencies (must be in scope when called):
  3861. * - userInfo: { userName }
  3862. * - settings: contains selectedSubL1, onlyShowApplicableLayers, onlyShowApplicableLayersZoom, visibleLayers, collapsedSections
  3863. * - _gisLayers: List of GIS layer objects, each with { id, name, countrySubL1, restrictTo }
  3864. * - NameMapper: object with method toFullName(subL1) -> string
  3865. * - jQuery ($)
  3866. * - Lodash (_)
  3867. * - Event handlers: onOnlyShowApplicableLayersChanged, onOnlyShowApplicableLayersZoomChanged, onSelectAllClick, onSelectNoneClick, onChevronClick, onGisLayerToggleChanged
  3868. *
  3869. * Side Effects:
  3870. * - Modifies the DOM inside #panel-gis-subL1-layers
  3871. * - Sets up interactive controls for GIS layer filtering and visibility
  3872. *
  3873. * @function
  3874. */
  3875. function initLayersTab() {
  3876. const subL1 = _.uniq(_gisLayers.map((l) => l.countrySubL1)).filter((sub) => settings?.selectedSubL1?.includes(sub));
  3877.  
  3878. $('#panel-gis-subL1-layers')
  3879. .empty()
  3880. .append(
  3881. $('<div>', { class: 'controls-container' })
  3882. .css({ 'padding-top': '0px' })
  3883. .append(
  3884. $('<input>', { type: 'checkbox', id: 'only-show-applicable-gis-layers' }).on('change', onOnlyShowApplicableLayersChanged).prop('checked', settings?.onlyShowApplicableLayers),
  3885. $('<label>', { for: 'only-show-applicable-gis-layers' }).css({ 'white-space': 'pre-line' }).text('Only show applicable layers for Region')
  3886. ),
  3887. $('<div>', { class: 'controls-container' })
  3888. .css({ 'padding-top': '0px' })
  3889. .append(
  3890. $('<input>', { type: 'checkbox', id: 'only-show-applicable-gis-layers-for-zoom-level' })
  3891. .on('change', onOnlyShowApplicableLayersZoomChanged)
  3892. .prop('checked', settings?.onlyShowApplicableLayersZoom),
  3893. $('<label>', { for: 'only-show-applicable-gis-layers-for-zoom-level' }).css({ 'white-space': 'pre-line' }).text('Include Zoom Level in filter')
  3894. ),
  3895. $('.gis-layers-subL1-checkbox:checked').length === 0
  3896. ? $('<div>').text('Turn on layer categories in the Settings tab.')
  3897. : subL1.map((sub) =>
  3898. $('<fieldset>', {
  3899. id: `gis-layers-for-${sub}`,
  3900. style: 'border:1px solid silver;padding:4px;border-radius:4px;-webkit-padding-before: 0;',
  3901. }).append(
  3902. $('<legend>', { style: 'margin-bottom:0px;border-bottom-style:none;width:auto;' })
  3903. .on('click', onChevronClick)
  3904. .append(
  3905. $('<i>', {
  3906. class: settings?.collapsedSections[sub] ? 'fa fa-fw fa-chevron-right' : 'fa fa-fw fa-chevron-down',
  3907. style: 'cursor: pointer;font-size: 12px;margin-right: 4px',
  3908. }),
  3909. $('<span>', {
  3910. style: 'font-size:14px;font-weight:600;text-transform: uppercase; cursor: pointer',
  3911. }).text(NameMapper.toFullName(sub))
  3912. ),
  3913. $('<div>', {
  3914. id: `${sub}_body`,
  3915. style: settings?.collapsedSections[sub] ? 'display: none;' : 'display: block;',
  3916. }).append(
  3917. $('<div>')
  3918. .css({ 'font-size': '11px' })
  3919. .append(
  3920. $('<span>').append('Select ', $('<a>', { href: '#' }).text('All').on('click', onSelectAllClick), ' / ', $('<a>', { href: '#' }).text('None').on('click', onSelectNoneClick))
  3921. ),
  3922. $('<div>', { class: 'controls-container', style: 'padding-top:0px;' }).append(
  3923. _gisLayers
  3924. .filter((l) => l.countrySubL1 === sub)
  3925. .map((gisLayer) => {
  3926. const id = `gis-layer-${gisLayer.id}`;
  3927. return $('<div>', { class: 'controls-container', id: `${id}-container` })
  3928. .css({ 'padding-top': '0px', display: 'block' })
  3929. .append(
  3930. $('<input>', { type: 'checkbox', id }).data('layer-id', gisLayer.id).on('change', onGisLayerToggleChanged).prop('checked', settings?.visibleLayers?.includes(gisLayer.id)),
  3931. $('<label>', { for: id, class: 'gis-subL1-layer-label' })
  3932. .css({ 'white-space': 'pre-line' })
  3933. .text(`${gisLayer.name}${gisLayer.restrictTo ? ' *' : ''}`)
  3934. .attr('title', gisLayer.restrictTo ? `Restricted to: ${gisLayer.restrictTo}` : '')
  3935. .on('contextmenu', (evt) => {
  3936. evt.preventDefault();
  3937. _layerSettingsDialog.gisLayer = gisLayer;
  3938. _layerSettingsDialog.show();
  3939. })
  3940. );
  3941. })
  3942. )
  3943. )
  3944. )
  3945. )
  3946. );
  3947. }
  3948.  
  3949. /**
  3950. * Initializes and renders the GIS Layers "Settings" tab UI.
  3951. *
  3952. * This function dynamically builds the user interface for the GIS settings panel,
  3953. * allowing users to control label display, popup options, country/group enablement,
  3954. * layer appearance (e.g., fill parcels), and manage special tokens for data access.
  3955. *
  3956. * Features:
  3957. * - Group GIS layers by country and present checkboxes for subregion enablement.
  3958. * - Provide radio buttons for address label and popup display settings.
  3959. * - Provide 'Select All' / 'Select None' batch controls for subregions per country.
  3960. * - Present appearance options (e.g., "Fill parcels" toggle).
  3961. * - Manage Tyler/Socrata App Token with in-panel input and help links.
  3962. * - Integrate custom group management and "Load All Layers" functionality.
  3963. * - Set up all necessary event handlers for user interactions (clicks/change, etc.).
  3964. *
  3965. * Dependencies (must be defined in scope at runtime):
  3966. * - _gisLayers: Array of GIS layer objects ({id, name, country, countrySubL1, ...})
  3967. * - settings: Object containing UI/user state/settings (see code for properties used)
  3968. * - NameMapper: Object/function mapping region codes to display names (`toFullName`)
  3969. * - SCRIPT_AUTHOR: String for author/contact (for tooltips)
  3970. * - jQuery ($), Lodash (_)
  3971. * - Event/callback handlers: onChevronClick, onSub1CheckChanged, onFillParcelsCheckedChanged, onGisAddrDisplayChange, openLayerGroupManagerDialog, batchUpdateSelectedSubL1, saveSettingsToStorage, loadSpreadsheetAsync, initTab, logDebug, logError, togglePopupVisibility
  3972. * - isPopupVisible: Boolean flag for popup state (mutated)
  3973. *
  3974. * Side Effects:
  3975. * - Rebuilds the DOM within #panel-gis-layers-settings
  3976. * - Registers event handlers and toggles settings state objects
  3977. * - May trigger async functions for loading layers/groups and updating settings
  3978. *
  3979. * @function initSettingsTab
  3980. * @returns {void}
  3981. */
  3982. function initSettingsTab() {
  3983. // Group layers by country
  3984. const layersByCountry = _.groupBy(_gisLayers, 'country');
  3985.  
  3986. /**
  3987. * Creates a radio input and label as jQuery objects.
  3988. * @param {string} name
  3989. * @param {string} value
  3990. * @param {string} text
  3991. * @param {boolean} checked
  3992. * @returns {Array} [input, label] as jQuery objects
  3993. */
  3994. function createRadioBtn(name, value, text, checked) {
  3995. const id = `${name}-${value}`;
  3996. return [
  3997. $('<input>', {
  3998. type: 'radio',
  3999. id,
  4000. name,
  4001. value,
  4002. }).prop('checked', checked),
  4003. $('<label>', { for: id }).text(text).css({
  4004. paddingLeft: '15px',
  4005. marginRight: '4px',
  4006. }),
  4007. ];
  4008. }
  4009.  
  4010. $('#panel-gis-layers-settings')
  4011. .empty()
  4012. .append(
  4013. $('<fieldset>', {
  4014. style: 'border:1px solid silver;padding:8px;border-radius:4px;-webkit-padding-before: 0;margin-top:-8px;',
  4015. }).append(
  4016. $('<legend>', {
  4017. style: 'margin-bottom:0px;border-bottom-style:none;width:auto;',
  4018. }).append(
  4019. $('<span>', {
  4020. style: 'font-size:14px;font-weight:600;text-transform: uppercase;',
  4021. }).text('Labels')
  4022. ),
  4023. $('<div>', { id: 'labelSettings' }).append(
  4024. $('<div>', { class: 'controls-container' })
  4025. .css({ 'padding-top': '2px' })
  4026. .append(
  4027. $('<label>', { style: 'font-weight:normal;' }).text('Addresses:'),
  4028. createRadioBtn('gisAddrDisplay', 'hn', 'HN', settings.addrLabelDisplay === 'hn'),
  4029. createRadioBtn('gisAddrDisplay', 'street', 'Street', settings.addrLabelDisplay === 'street'),
  4030. createRadioBtn('gisAddrDisplay', 'all', 'Both', settings.addrLabelDisplay === 'all'),
  4031. createRadioBtn('gisAddrDisplay', 'none', 'None', settings.addrLabelDisplay === 'none'),
  4032. // You may get TS errors for tooltip() unless you declare it (see previous answer)
  4033. $('<i>', {
  4034. class: 'waze-tooltip',
  4035. id: 'gisAddrDisplayInfo',
  4036. 'data-toggle': 'tooltip',
  4037. style: 'margin-left:8px; font-size:12px',
  4038. 'data-placement': 'bottom',
  4039. title: `This may not work properly for all layers. Please report issues to ${SCRIPT_AUTHOR}.`,
  4040. }).tooltip(),
  4041. $('<br>'),
  4042. $('<label>', { style: 'font-weight:normal; margin-left:8px;' }).text('Label Popup:'),
  4043. createRadioBtn('popupVisibility', 'show', 'Show', isPopupVisible),
  4044. createRadioBtn('popupVisibility', 'hide', 'Hide', !isPopupVisible)
  4045. )
  4046. )
  4047. )
  4048. );
  4049.  
  4050. // Create groups by country
  4051. Object.keys(layersByCountry)
  4052. .sort()
  4053. .forEach((country) => {
  4054. const subRegions = _.uniq(layersByCountry[country].map((l) => l.countrySubL1));
  4055. // Unique selector base for this country
  4056. const countryContainerId = `country_${country}_body`;
  4057.  
  4058. $('#panel-gis-layers-settings').append(
  4059. $('<fieldset>', { style: 'border:1px solid silver;padding:8px;border-radius:4px;-webkit-padding-before:0;' }).append(
  4060. $('<legend>', { style: 'margin-bottom:0px;border-bottom-style:none;width:auto;' })
  4061. // OLD: .click(onChevronClick) -- DEPRECATED
  4062. .on('click', onChevronClick)
  4063. .append(
  4064. $('<i>', { class: 'fa fa-fw fa-chevron-down', style: 'cursor: pointer;font-size: 12px;margin-right: 4px' }),
  4065. $('<span>', { style: 'font-size:14px;font-weight:600;text-transform:uppercase;' }).text(NameMapper.toFullName(country))
  4066. ),
  4067. $('<div>', { id: countryContainerId }).append(
  4068. // One Select All/None row per COUNTRY
  4069. $('<div>', { class: 'gis-select-all-controls', style: 'font-size:11px;margin-bottom:4px;' }).append(
  4070. 'Select ',
  4071. $('<a>', { href: '#', 'data-country': country, class: 'gis-select-all-country' }).text('All'),
  4072. ' / ',
  4073. $('<a>', { href: '#', 'data-country': country, class: 'gis-select-none-country' }).text('None')
  4074. ),
  4075. // All the subregion checkboxes
  4076. subRegions.map((countrySubL1) => {
  4077. const fullName = NameMapper.toFullName(countrySubL1);
  4078. const id = `gis-layer-enable-subL1-${countrySubL1}`;
  4079. return $('<div>', { class: 'controls-container' })
  4080. .css({ 'padding-top': '0px', display: 'block' })
  4081. .append(
  4082. $('<input>', {
  4083. type: 'checkbox',
  4084. id,
  4085. class: 'gis-layers-subL1-checkbox',
  4086. 'data-sub': countrySubL1,
  4087. 'data-country': country,
  4088. })
  4089. .on('change', (evt) => onSub1CheckChanged(countrySubL1, evt)) // <--- pass subL1
  4090. .prop('checked', settings.selectedSubL1.includes(countrySubL1)),
  4091. $('<label>', { for: id }).css({ 'white-space': 'pre-line' }).text(fullName)
  4092. );
  4093. })
  4094. )
  4095. )
  4096. );
  4097. });
  4098.  
  4099. $('#panel-gis-layers-settings').append(
  4100. $('<fieldset>', { style: 'border:1px solid silver;padding:8px;border-radius:4px;-webkit-padding-before:0;' }).append(
  4101. $('<legend>', { style: 'margin-bottom:0px;border-bottom-style:none;width:auto;' }).append(
  4102. $('<span>', { style: 'font-size:14px;font-weight:600;text-transform:uppercase;' }).text('Appearance')
  4103. ),
  4104. $('<div>', { class: 'controls-container' })
  4105. .css({ 'padding-top': '2px' })
  4106. .append(
  4107. $('<input>', { type: 'checkbox', id: 'fill-parcels' }).change(onFillParcelsCheckedChanged).prop('checked', settings.fillParcels),
  4108. $('<label>', { for: 'fill-parcels' }).css({ 'white-space': 'pre-line' }).text('Fill parcels')
  4109. )
  4110. )
  4111. );
  4112.  
  4113. // ---- SOCRATA APP TOKEN SECTION ----
  4114. $('#panel-gis-layers-settings').append('<div id="socrata-app-token-anchor"></div>');
  4115.  
  4116. function renderSocrataAppTokenSection() {
  4117. $('#socrata-app-token-section').remove();
  4118.  
  4119. const hasToken = !!settings.socrataAppToken;
  4120. const inputType = hasToken ? 'password' : 'text';
  4121. const inputVal = hasToken ? settings.socrataAppToken : '';
  4122. const inputPh = hasToken ? 'Token is set' : 'Enter Socrata App Token';
  4123. const btnLabel = hasToken ? 'Remove' : 'Save';
  4124.  
  4125. const $fieldset = $('<fieldset>', {
  4126. id: 'socrata-app-token-section',
  4127. style: 'border:1px solid #b9b9b9;margin-top:6px;padding:8px;border-radius:4px;',
  4128. }).append(
  4129. $('<legend>', {
  4130. style: 'margin-bottom:0px;border-bottom-style:none;width:auto;',
  4131. }).append(
  4132. $('<span>', {
  4133. style: 'font-size:14px;font-weight:600;text-transform:uppercase;',
  4134. }).text('Tyler/Socrata App Token')
  4135. ),
  4136. $('<div>', {
  4137. style: ['display:flex', 'gap:8px', 'align-items:center', 'border:1px solid #b9b9b9', 'border-radius:4px', 'padding:4px 8px'].join(';'),
  4138. }).append(
  4139. $('<input>', {
  4140. type: inputType,
  4141. id: 'socrata-app-token-input',
  4142. style: ['flex:1 1 auto', 'border:none', 'background:transparent', 'outline:none', 'font-size:12px', 'padding:4px 0', 'color:inherit'].join(';'),
  4143. placeholder: inputPh,
  4144. disabled: hasToken, // disable input when token is set
  4145. }).val(inputVal),
  4146. $('<button>', {
  4147. id: 'save-socrata-app-token-btn',
  4148. style: ['border:none', 'background:transparent', 'color:#335', 'margin:0 2px', 'padding:2px 10px', 'border-radius:3px', 'font-size:13px', 'cursor:pointer'].join(';'),
  4149. text: btnLabel,
  4150. })
  4151. ),
  4152. $('<div>', {
  4153. style: 'margin:6px 2px 0 2px;',
  4154. }).append(
  4155. $('<span>', {
  4156. style: 'color:#777;font-size:11px;',
  4157. html: 'Recommended for all <b>·</b> <span style="color:#b00;">Required for V3 API</span>',
  4158. })
  4159. )
  4160. );
  4161.  
  4162. if (!hasToken) {
  4163. // Show help links if token is not set
  4164. const $helpDiv = $('<div>', {
  4165. style: 'margin:2px 2px 0 2px;font-size:11px;',
  4166. }).append(
  4167. $('<div>').append(
  4168. $('<a>', {
  4169. href: 'https://support.socrata.com/hc/en-us/articles/115004055807-How-to-Sign-Up-for-a-Tyler-Data-Insights-ID',
  4170. target: '_blank',
  4171. rel: 'noopener noreferrer',
  4172. style: 'color:#357ab8;text-decoration:underline;',
  4173. text: 'How to Sign Up for a Tyler Data & Insights ID',
  4174. })
  4175. ),
  4176. $('<div>').append(
  4177. $('<a>', {
  4178. href: 'https://support.socrata.com/hc/en-us/articles/210138558-Generating-App-Tokens-and-API-Keys',
  4179. target: '_blank',
  4180. rel: 'noopener noreferrer',
  4181. style: 'color:#357ab8;text-decoration:underline;',
  4182. text: 'How to Generating App Tokens',
  4183. })
  4184. )
  4185. );
  4186. $fieldset.append($helpDiv);
  4187. }
  4188.  
  4189. // (insert after anchor)
  4190. $('#socrata-app-token-anchor').after($fieldset);
  4191.  
  4192. // Single handler for the button
  4193. $('#save-socrata-app-token-btn')
  4194. .off('click')
  4195. .on('click', function () {
  4196. if (!hasToken) {
  4197. const token = String($('#socrata-app-token-input').val()).trim();
  4198. settings.socrataAppToken = token;
  4199. saveSettingsToStorage();
  4200. $(this)
  4201. .text('Saved!')
  4202. .delay(1000)
  4203. .queue(function (next) {
  4204. $(this).text('Remove');
  4205. next();
  4206. });
  4207. } else {
  4208. // Remove the token
  4209. settings.socrataAppToken = '';
  4210. saveSettingsToStorage();
  4211. }
  4212. renderSocrataAppTokenSection();
  4213. });
  4214. }
  4215.  
  4216. renderSocrataAppTokenSection();
  4217. // ---- SOCRATA APP TOKEN SECTION END
  4218.  
  4219. $('input[name="gisAddrDisplay"]').on('change', onGisAddrDisplayChange);
  4220.  
  4221. $('input[name="popupVisibility"]').on('change', function () {
  4222. isPopupVisible = $(this).val() === 'show';
  4223. togglePopupVisibility();
  4224. });
  4225.  
  4226. // -- CUSTOM Group Popup & Load All Button --
  4227. $('#panel-gis-layers-settings').append(
  4228. $('<fieldset>', { style: 'border:1px solid #8ea0b7;margin-top:6px;padding:8px;border-radius:4px;' }).append(
  4229. $('<legend>', { style: 'margin-bottom:0px;border-bottom-style:none;width:auto;' }).append(
  4230. $('<span>', { style: 'font-size:14px;font-weight:600;text-transform:uppercase;' }).text('Layer Groupings')
  4231. ),
  4232. $('<div>').append(
  4233. $('<button>', {
  4234. id: 'gis-manager-launch-btn',
  4235. class: 'form-control',
  4236. style: 'display:inline-block;padding:2px 8px;margin-top:3px; background:#4d6a88; color:#eaf6ff; border:1px solid #50667b;',
  4237. }).text('Manage Custom Groups'),
  4238. $('<button>', {
  4239. id: 'gis-load-all-btn',
  4240. class: 'form-control',
  4241. style: 'display:inline-block;padding:2px 8px;margin-top:3px;background:#548342;color:#fff;border:1px solid #406927;',
  4242. title: 'Load ALL country/state/region layers for custom grouping (slower)',
  4243. }).text('Load All Layers')
  4244. )
  4245. )
  4246. );
  4247.  
  4248. $('#gis-manager-launch-btn').off('click').on('click', openLayerGroupManagerDialog);
  4249.  
  4250. $('#gis-load-all-btn')
  4251. .off('click')
  4252. .on('click', async function () {
  4253. $(this).prop('disabled', true).text('Loading...');
  4254. try {
  4255. await loadSpreadsheetAsync('ALL', 'ALL');
  4256. initTab(false);
  4257. logDebug('All layers loaded!');
  4258. } catch (e) {
  4259. logError(`Error in load all Layers: ${e.message || e}`);
  4260. }
  4261. $(this).prop('disabled', false).text('Load All Layers');
  4262. });
  4263. // -- END CUSTOM Group Popup & Load All Button --
  4264.  
  4265. // Select all subregions under a country functionality
  4266. $('#panel-gis-layers-settings')
  4267. .off('click', '.gis-select-all-country')
  4268. .on('click', '.gis-select-all-country', async function (e) {
  4269. e.preventDefault();
  4270. const country = $(this).data('country');
  4271. // Check all
  4272. $(`.gis-layers-subL1-checkbox[data-country="${country}"]`).prop('checked', true);
  4273. await batchUpdateSelectedSubL1(); // <- collect and process only ONCE!
  4274. });
  4275.  
  4276. $('#panel-gis-layers-settings')
  4277. .off('click', '.gis-select-none-country')
  4278. .on('click', '.gis-select-none-country', async function (e) {
  4279. e.preventDefault();
  4280. const country = $(this).data('country');
  4281. // Uncheck all
  4282. $(`.gis-layers-subL1-checkbox[data-country="${country}"]`).prop('checked', false);
  4283. await batchUpdateSelectedSubL1(); // <- collect and process only ONCE!
  4284. });
  4285. }
  4286.  
  4287. /**
  4288. * Initializes the GIS Layers tab UI.
  4289. *
  4290. * - On the first call, generates tab content dynamically and registers the tab with the sidebar.
  4291. * - Sets various UI elements: labels, buttons, a report request link, refresh icon, and settings panel.
  4292. * - Wires up event handlers for toggling GIS Layers and refreshing layer info.
  4293. * - Always calls sub-initializers for settings and layers.
  4294. *
  4295. * @async
  4296. * @function initTab
  4297. * @param {boolean} [firstCall=true] - Whether this is the first initialization (controls tab registration and content rendering).
  4298. * @returns {Promise<void>} Resolves when initialization is complete.
  4299. */
  4300. async function initTab(firstCall = true) {
  4301. if (firstCall) {
  4302. // Build the tab content UI, including version, report/request link, refresh button, and tab panes.
  4303. const content = $('<div>')
  4304. .append(
  4305. // Script name and version.
  4306. $('<span>', { style: 'font-size:14px;font-weight:600' }).text('GIS Layers'),
  4307. $('<span>', { style: 'font-size:11px;margin-left:10px;color:#aaa;' }).text(GM_info.script.version),
  4308. // Report/request Google Form link.
  4309. $('<a>', {
  4310. href: REQUEST_FORM_URL.replace('{username}', userInfo?.userName ?? ''),
  4311. target: '_blank',
  4312. style: 'color: #6290b7;font-size: 12px;margin-left: 8px;',
  4313. title: 'Report broken layers, bugs, request new layers, script features',
  4314. }).text('Submit a request'),
  4315. // Refresh icon.
  4316. $('<span>', {
  4317. id: 'gis-layers-refresh',
  4318. class: 'fa fa-refresh',
  4319. style: 'float: right;',
  4320. 'data-toggle': 'tooltip',
  4321. title: 'Pull new layer info from master sheet and refresh all layers.',
  4322. }),
  4323. // Nav tabs for layer/settings panels.
  4324. '<ul class="nav nav-tabs">' +
  4325. '<li class="active"><a data-toggle="tab" href="#panel-gis-subL1-layers" aria-expanded="true">Layers</a></li>' +
  4326. '<li><a data-toggle="tab" href="#panel-gis-layers-settings" aria-expanded="true">Settings</a></li>' +
  4327. '</ul>',
  4328. // Tab panels for layers and settings.
  4329. $('<div>', { class: 'tab-content', style: 'padding:8px;padding-top:2px' }).append(
  4330. $('<div>', { class: 'tab-pane active', id: 'panel-gis-subL1-layers', style: 'padding: 4px 0px 0px 0px; width: auto' }),
  4331. $('<div>', { class: 'tab-pane', id: 'panel-gis-layers-settings', style: 'padding: 4px 0px 0px 0px; width: auto' })
  4332. )
  4333. )
  4334. .html();
  4335.  
  4336. // Build the "power" button and label.
  4337. const powerButtonColor = settings.enabled ? '#00bd00' : '#ccc';
  4338. const labelText = $('<div>')
  4339. .append(
  4340. $('<span>', {
  4341. class: 'fa fa-power-off',
  4342. id: 'gis-layers-power-btn',
  4343. style: `margin-right: 5px;cursor: pointer;color: ${powerButtonColor};font-size: 13px;`,
  4344. title: 'Toggle GIS Layers',
  4345. }),
  4346. $('<span>', { title: 'GIS Layers' }).text('GIS-L')
  4347. )
  4348. .html();
  4349.  
  4350. // Register a new script tab in the sidebar and fill in content.
  4351. const { tabLabel, tabPane } = await sdk.Sidebar.registerScriptTab();
  4352. tabLabel.innerHTML = labelText;
  4353. tabPane.innerHTML = content;
  4354.  
  4355. // Tweak tab spacing and wire up power and refresh buttons.
  4356. $(tabPane).parent().css({ width: 'auto', padding: '6px' });
  4357. $('#gis-layers-power-btn').on('click', function () {
  4358. setEnabled(!settings.enabled);
  4359.  
  4360. // Prevent parent tab activation when toggling GIS-Layers.
  4361. return false;
  4362. });
  4363.  
  4364. $('#gis-layers-refresh').on('click', onRefreshLayersClick);
  4365. }
  4366.  
  4367. // Always initialize settings and layer panels.
  4368. initSettingsTab();
  4369. initLayersTab();
  4370. }
  4371.  
  4372. /**
  4373. * Initializes the GIS Layers script UI and event handlers, including tab content, layer controls, and listeners.
  4374. *
  4375. * - On first initialization, sets up the layer tab, adds the GIS Layers checkbox to the Layer Switcher,
  4376. * synchronizes its checked state with settings, subscribes to layer and map events, and displays the script info alert.
  4377. * - On subsequent calls, reinitializes the tab contents with the current state.
  4378. *
  4379. * @function initGui
  4380. * @param {boolean} [firstCall=true] - Whether this is the initial setup or a subsequent refresh.
  4381. * @returns {void}
  4382. */
  4383. function initGui(firstCall = true) {
  4384. initLayer();
  4385. if (firstCall) {
  4386. initTab(true);
  4387. sdk.LayerSwitcher.addLayerCheckbox({ name: 'GIS Layers' });
  4388. sdk.LayerSwitcher.setLayerCheckboxChecked({ name: 'GIS Layers', isChecked: settings.enabled });
  4389. sdk.Events.on({ eventName: 'wme-layer-checkbox-toggled', eventHandler: onLayerCheckboxChanged });
  4390. sdk.Events.on({ eventName: 'wme-map-move-end', eventHandler: onMapMove });
  4391. showScriptInfoAlert();
  4392. } else {
  4393. initTab(firstCall);
  4394. }
  4395. }
  4396.  
  4397. /**
  4398. * Opens the GIS Layer Group Manager dialog for managing saved layer/region groups.
  4399. *
  4400. * - Renders a draggable dialog unless already open.
  4401. * - Allows the user to save, load, and delete "layer groups": sets of currently selected regions and visible GIS layers.
  4402. * - Integrates with `settings` (for state), WazeWrap.Alerts (for confirmation/prompt), and uses jQuery for UI.
  4403. * - Cleans up event handlers on close/escape.
  4404. *
  4405. * @function openLayerGroupManagerDialog
  4406. * @returns {void}
  4407. */
  4408. function openLayerGroupManagerDialog() {
  4409. if ($('#gis-layer-group-dialog').length) return;
  4410.  
  4411. // --- Color & style constants for easy palette harmonization ---
  4412. const BTN_STYLE_BLUE =
  4413. 'min-width:120px;height:38px;display:flex;align-items:center;justify-content:center;' +
  4414. 'border:1.5px solid #50667b;border-radius:7px; font-size:15px;font-weight:600;' +
  4415. 'background:#4d6a88;color:#eaf6ff;box-shadow:0 2px 7px #35587015;cursor:pointer; outline:none;';
  4416. const BTN_STYLE_GREEN =
  4417. 'min-width:120px;height:38px;display:flex;align-items:center;justify-content:center;' +
  4418. 'border:1.5px solid #406927;border-radius:7px;font-size:15px;font-weight:600;' +
  4419. 'background:#548342;color:#fff;box-shadow:0 2px 7px #35587015;cursor:pointer; outline:none;';
  4420. const BTN_STYLE_RED =
  4421. 'min-width:120px;height:38px;display:flex;align-items:center;justify-content:center;' +
  4422. 'border:1.5px solid #9b2020;border-radius:7px;font-size:15px;font-weight:600;' +
  4423. 'background:#c14444;color:#fff;box-shadow:0 2px 7px #35587015;cursor:pointer; outline:none;';
  4424. const BTN_STYLE_ORANGE =
  4425. 'min-width:120px;height:38px;display:flex;align-items:center;justify-content:center;' +
  4426. 'border:1.5px solid #9c5b13;border-radius:7px;font-size:15px;font-weight:600;' +
  4427. 'background:#d58431;color:#fff;box-shadow:0 2px 7px #35587015;cursor:pointer; outline:none;';
  4428.  
  4429. const scriptName = typeof GM_info !== 'undefined' ? GM_info.script.name : 'Layer Groups';
  4430.  
  4431. // Header and close
  4432. const $title = $('<span>').text(scriptName + ' — Layer Groups');
  4433. const $close = $('<span>', {
  4434. style: 'cursor:pointer;padding-left:14px;font-size:20px;color:#eaf6ff;float:right;',
  4435. class: 'fa fa-window-close',
  4436. title: 'Close',
  4437. tabindex: 0,
  4438. }).on('click keydown', function (e) {
  4439. if (e.type === 'click' || (e.type === 'keydown' && (e.key === 'Enter' || e.key === ' '))) $dlg.remove();
  4440. });
  4441.  
  4442. // Dialog container
  4443. const $dlg = $('<div>', {
  4444. id: 'gis-layer-group-dialog',
  4445. style:
  4446. 'position:fixed; top:14%; left:420px; width:400px; z-index:99999;' +
  4447. 'background:#73a9bd; border-width:1px; border-style:solid; border-radius:14px;' +
  4448. 'box-shadow:5px 6px 14px rgba(0,0,0,0.58); border-color:#50667b; padding:0; font-family:inherit;',
  4449. });
  4450.  
  4451. // Header
  4452. $dlg.append(
  4453. $('<div>', {
  4454. style: 'border-radius:14px 14px 0px 0px; padding: 7px 14px; color: #fff; background:#4d6a88; font-weight:bold; text-align:left; font-size:17px;',
  4455. }).append($title, $close)
  4456. );
  4457.  
  4458. // --- Section: Current Selection ---
  4459. const $section1 = $('<div>', {
  4460. style: 'border-radius: 7px; background: #d6e6f3; margin:8px 8px 8px 8px; padding:8px 8px 8px 8px; box-shadow:0 1px 5px #0001;',
  4461. }).append(
  4462. $('<div>', { style: 'font-size:15.5px;font-weight:700;color:#355870;margin-bottom:6px;' }).text('Current Selection'),
  4463. $('<div>', { style: 'font-size:13.3px;color:#468;margin-bottom:13px;' }).text('Save or load your current visible layers and region selections as quick-access groups.'),
  4464. $('<div>', { style: 'display:flex;gap:14px;align-items:center;margin-top:4px;' }).append(
  4465. $('<button>', {
  4466. class: 'GISGroupDlg-btn',
  4467. style: BTN_STYLE_RED,
  4468. title: 'Remove all selected sub-regions and visible layers',
  4469. })
  4470. .text('Clear All')
  4471. .on('click', function () {
  4472. WazeWrap.Alerts.confirm(
  4473. scriptName,
  4474. '<div style="color:#ff0000; font-size:17px; font-weight:bold; padding:10px 0; text-align:center;">' +
  4475. 'Are you sure you want to remove all visible layers, and region selections?' +
  4476. '</div>',
  4477. function () {
  4478. settings.selectedSubL1 = [];
  4479. settings.visibleLayers = [];
  4480. settings.collapsedSections = {};
  4481. saveSettingsToStorage();
  4482. loadSettingsFromStorage();
  4483. initGui(false);
  4484. $dlg.remove();
  4485. }
  4486. );
  4487. }),
  4488. $('<button>', {
  4489. class: 'GISGroupDlg-btn',
  4490. style: BTN_STYLE_BLUE,
  4491. title: 'Save current layers and selections as a group',
  4492. })
  4493. .text('Save as Group')
  4494. .on('click', function () {
  4495. WazeWrap.Alerts.prompt(scriptName, 'Enter a name for this group:', '', function (result, name) {
  4496. if (!result || !name || !name.trim()) return;
  4497. settings.layerGroups = settings.layerGroups || {};
  4498. if (settings.layerGroups[name]) {
  4499. WazeWrap.Alerts.confirm(scriptName, 'Group "' + name + '" exists. Overwrite?', function () {
  4500. doSaveGroup(name, true);
  4501. });
  4502. } else {
  4503. doSaveGroup(name, false);
  4504. }
  4505. /**
  4506. * @param {string} groupName - Name for the saved group.
  4507. * @param {boolean} overwritten - If true, notify user it's an overwrite.
  4508. * @returns {void}
  4509. */
  4510. function doSaveGroup(groupName, overwritten) {
  4511. settings.layerGroups[groupName] = {
  4512. selectedSubL1: [...settings.selectedSubL1],
  4513. visibleLayers: [...settings.visibleLayers],
  4514. collapsedSections: { ...settings.collapsedSections },
  4515. addrLabelDisplay: settings.addrLabelDisplay,
  4516. fillParcels: settings.fillParcels,
  4517. };
  4518. saveSettingsToStorage();
  4519. loadSettingsFromStorage();
  4520. populateGroupSelect();
  4521. setTimeout(function () {
  4522. if (typeof WazeWrap !== 'undefined' && WazeWrap.Alerts && typeof WazeWrap.Alerts.success === 'function') {
  4523. WazeWrap.Alerts.success(scriptName, 'Layer group saved as "' + groupName + '"' + (overwritten ? ' (overwritten)' : ''));
  4524. } else {
  4525. alert('Layer group saved as "' + groupName + '"' + (overwritten ? ' (overwritten)' : ''));
  4526. }
  4527. }, 150);
  4528. }
  4529. });
  4530. })
  4531. )
  4532. );
  4533.  
  4534. // --- Section: My Saved Groups ---
  4535. const $groupSelect = $('<select>', {
  4536. id: 'gis-layer-group-select',
  4537. style:
  4538. 'font-size:13px; border-radius:4px; border:1px solid #356079; padding:7px 12px;' +
  4539. 'min-width:250px; max-width:365px; margin-right:8px; outline:none;' +
  4540. 'background:#eaf4fd; color:#17354e; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;',
  4541. });
  4542.  
  4543. /**
  4544. * Populates the group selection drop-down with saved layer groups from settings.
  4545. * If no groups exist, shows a disabled "No groups saved" message.
  4546. *
  4547. * @function populateGroupSelect
  4548. * @returns {void}
  4549. */
  4550. function populateGroupSelect() {
  4551. $groupSelect.empty();
  4552. const groups = settings.layerGroups || {};
  4553. if (Object.keys(groups).length === 0) {
  4554. $groupSelect.append($('<option>', { disabled: true, selected: true, text: 'No groups saved' }));
  4555. return;
  4556. }
  4557. $groupSelect.append($('<option>', { selected: true, disabled: true, text: 'Select group...' }));
  4558. Object.keys(groups).forEach((groupName) => {
  4559. $groupSelect.append($('<option>', { value: groupName, text: groupName, title: groupName }));
  4560. });
  4561. }
  4562. populateGroupSelect();
  4563.  
  4564. const $section2 = $('<div>', {
  4565. style: 'border-radius: 7px; background: #d6e6f3; margin:8px 8px 8px 8px; padding:8px 8px 8px 8px; box-shadow:0 1px 5px #0001;',
  4566. }).append(
  4567. $('<div>', { style: 'font-size:14.5px;font-weight:700;color:#355870;margin-bottom:10px;' }).text('My Saved Groups'),
  4568. $('<div>', { style: 'margin-bottom:8px;' }).append($groupSelect),
  4569. $('<div>', { style: 'display:flex;gap:12px;align-items:center;margin-top:6px;' }).append(
  4570. $('<button>', {
  4571. class: 'GISGroupDlg-btn',
  4572. style: BTN_STYLE_GREEN,
  4573. title: 'Load selected group',
  4574. })
  4575. .text('Load Group')
  4576. .on('click', function () {
  4577. const group = $groupSelect.val();
  4578. if (typeof group !== 'string' || !(settings.layerGroups && settings.layerGroups[group])) {
  4579. if (typeof WazeWrap !== 'undefined' && WazeWrap.Alerts && typeof WazeWrap.Alerts.info === 'function') {
  4580. WazeWrap.Alerts.info(scriptName, 'Please select a group to load.');
  4581. } else {
  4582. alert('Please select a group to load.');
  4583. }
  4584. return;
  4585. }
  4586. const grp = settings.layerGroups[group];
  4587. settings.selectedSubL1 = [...grp.selectedSubL1];
  4588. settings.visibleLayers = [...grp.visibleLayers];
  4589. settings.collapsedSections = { ...grp.collapsedSections };
  4590. settings.addrLabelDisplay = grp.addrLabelDisplay;
  4591. settings.fillParcels = grp.fillParcels;
  4592. saveSettingsToStorage();
  4593. loadSettingsFromStorage();
  4594. initGui(false);
  4595. $dlg.remove();
  4596. }),
  4597. $('<button>', {
  4598. class: 'GISGroupDlg-btn',
  4599. style: BTN_STYLE_ORANGE,
  4600. title: 'Delete selected group',
  4601. })
  4602. .text('Delete Group')
  4603. .on('click', function () {
  4604. const group = $groupSelect.val();
  4605. if (typeof group !== 'string' || !(settings.layerGroups && settings.layerGroups[group])) {
  4606. if (typeof WazeWrap !== 'undefined' && WazeWrap.Alerts && typeof WazeWrap.Alerts.info === 'function') {
  4607. WazeWrap.Alerts.info(scriptName, 'Please select a group to delete.');
  4608. } else {
  4609. alert('Please select a group to delete.');
  4610. }
  4611. return;
  4612. }
  4613. WazeWrap.Alerts.confirm(
  4614. scriptName,
  4615. '<div style="color:#ff0000; font-size:17px; font-weight:bold; padding:10px 0; text-align:Left;">' + 'Delete group "' + group + '"? \nThis cannot be undone!' + '</div>',
  4616. function () {
  4617. delete settings.layerGroups[group];
  4618. saveSettingsToStorage();
  4619. loadSettingsFromStorage();
  4620. populateGroupSelect();
  4621. setTimeout(function () {
  4622. if (typeof WazeWrap !== 'undefined' && WazeWrap.Alerts && typeof WazeWrap.Alerts.success === 'function') {
  4623. WazeWrap.Alerts.success(scriptName, 'Group "' + group + '" deleted.');
  4624. } else {
  4625. alert('Group "' + group + '" deleted.');
  4626. }
  4627. }, 150);
  4628. }
  4629. );
  4630. })
  4631. )
  4632. );
  4633.  
  4634. // Build and insert dialog
  4635. $dlg.append($section1, $section2);
  4636. $dlg.appendTo('body');
  4637.  
  4638. // Make draggable if possible
  4639. if (typeof jQuery.ui !== 'undefined') $dlg.draggable({ stop: () => $dlg.css('height', '') });
  4640.  
  4641. $(document).on('keydown.gisLayerDialog', function (e) {
  4642. if (e.key === 'Escape') $dlg.remove();
  4643. });
  4644. $dlg.on('remove', () => $(document).off('keydown.gisLayerDialog'));
  4645. }
  4646.  
  4647. /**
  4648. * Asynchronously loads GIS data for visible countries and subdivisions within the current map viewport.
  4649. *
  4650. * This function fetches data associated with countries and their subdivisions that are visible at the current zoom
  4651. * level. It avoids redundant data loads by tracking which countries and subdivisions have already been processed,
  4652. * thereby optimizing resource usage and enhancing loading efficiency.
  4653. *
  4654. * Process Overview:
  4655. * 1. Checks the current zoom level and returns early if below the threshold, preventing data loading.
  4656. * 2. Calls `whatsInView` to populate `_whatsInView` with currently visible country and subdivision data.
  4657. * 3. Iterates over `_whatsInView` to extract unique country codes (`ISO_ALPHA3`) and subdivision codes (`subL1_id`).
  4658. * 4. For each country code:
  4659. * - If it's not already loaded, initializes loading for all visible subdivisions.
  4660. * - For countries already loaded, filters subdivisions that haven't been loaded yet.
  4661. * - Calls `loadSpreadsheetAsync` to fetch and load the data and then updates the GUI.
  4662. * 5. Tracks loaded subdivisions to prevent redundancy and logs the loading activity for debugging.
  4663. *
  4664. * Features:
  4665. * - Efficiently manages GIS data loading based on visibility and ensures GUI updating post-data fetch.
  4666. * - Uses sets to maintain unique country and region codes, enhancing data consistency.
  4667. *
  4668. * Parameters:
  4669. * - No explicit parameters, utilizes global variables and state tracking.
  4670. *
  4671. * @returns {Promise<void>} - No explicit return; relies on side effects to update global state and UI.
  4672. */
  4673. async function loadVisibleCountryData() {
  4674. try {
  4675. // Only load at suitable zoom levels
  4676. const currentZoomLevel = sdk.Map.getZoomLevel();
  4677. if (currentZoomLevel < 12) return;
  4678.  
  4679. await whatsInView();
  4680.  
  4681. /** @type {Set<string>} */
  4682. const countryCodes = new Set();
  4683. /** @type {Record<string, Set<string>>} */
  4684. const countryRegionCodes = {};
  4685.  
  4686. // Collect visible country and subdivision codes
  4687. for (const countryKey in _whatsInView) {
  4688. if (!_whatsInView.hasOwnProperty(countryKey)) continue;
  4689. const c = _whatsInView[countryKey];
  4690. if (!c?.ISO_ALPHA3) continue;
  4691. countryCodes.add(c.ISO_ALPHA3);
  4692. const regionSet = new Set();
  4693. if (c.subL1) {
  4694. for (const subCode in c.subL1) {
  4695. if (!c.subL1.hasOwnProperty(subCode)) continue;
  4696. const sub = c.subL1[subCode];
  4697. if (sub?.subL1_id) regionSet.add(sub.subL1_id);
  4698. }
  4699. }
  4700. countryRegionCodes[c.ISO_ALPHA3] = regionSet;
  4701. }
  4702.  
  4703. // For each country, determine which regions need loading
  4704. for (const isoCode of countryCodes) {
  4705. const regionCodes = countryRegionCodes[isoCode];
  4706. const newRegionCodesToLoad = new Set();
  4707. let needToLoad = false;
  4708.  
  4709. if (!alreadyLoadedCountries.has(isoCode)) {
  4710. // First load for this country
  4711. regionCodes.forEach((r) => newRegionCodesToLoad.add(r));
  4712. needToLoad = true;
  4713. } else {
  4714. // Already loaded; only new visible subdivisions
  4715. regionCodes.forEach((regionCode) => {
  4716. if (!alreadyLoadedSubL1.has(regionCode)) {
  4717. newRegionCodesToLoad.add(regionCode);
  4718. needToLoad = true;
  4719. }
  4720. });
  4721. }
  4722.  
  4723. if (needToLoad) {
  4724. await loadSpreadsheetAsync(isoCode, newRegionCodesToLoad);
  4725. alreadyLoadedCountries.add(isoCode);
  4726. initGui(false);
  4727. newRegionCodesToLoad.forEach((regionCode) => alreadyLoadedSubL1.add(regionCode));
  4728. }
  4729. }
  4730. } catch (error) {
  4731. logError(`Error in loadVisibleCountryData: ${error && error.message ? error.message : error}`);
  4732. throw error;
  4733. }
  4734. }
  4735.  
  4736. /**
  4737. * Compare two version strings ("2025.08.01.00", "2018.04.27.001")
  4738. * Returns 1 if a > b, -1 if a < b, 0 if equal
  4739. * @param {string} a
  4740. * @param {string} b
  4741. * @returns {number}
  4742. */
  4743. function compareVersions(a, b) {
  4744. const splitA = a.split('.').map(Number);
  4745. const splitB = b.split('.').map(Number);
  4746. const maxLen = Math.max(splitA.length, splitB.length);
  4747. for (let i = 0; i < maxLen; i++) {
  4748. const numA = splitA[i] || 0;
  4749. const numB = splitB[i] || 0;
  4750. if (numA > numB) return 1;
  4751. if (numA < numB) return -1;
  4752. }
  4753. return 0;
  4754. }
  4755.  
  4756. /**
  4757. * Asynchronously loads GIS layer definitions from a Google Sheets spreadsheet.
  4758. *
  4759. * Fetches layer configuration data from a fixed tab in a Google Sheet using the Visualization API endpoint,
  4760. * then parses, filters, and augments the data based on the provided country ISO code and region codes.
  4761. * Returns an object with an error string if something goes wrong, or null if successful.
  4762. *
  4763. * @param {string} isoCode - Country ISO code, or "ALL" to load all layers.
  4764. * @param {Set<string>|string} regionCodes - Set of region/subdivision codes, or "ALL" to load for all.
  4765. * @returns {Promise<{ error: string|null }>} Promise resolving to { error } object.
  4766. */
  4767. async function loadSpreadsheetAsync(isoCode, regionCodes) {
  4768. const TAB_NAME = 'Layer Definitions v2';
  4769. const SHEET_ID = '1cEG3CvXSCI4TOZyMQTI50SQGbVhJ48Xip-jjWg4blWw';
  4770. const LAYER_DEF_URL = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:json&sheet=${encodeURIComponent(TAB_NAME)}`;
  4771.  
  4772. const FIELD_INDEXES = {
  4773. country: 0,
  4774. subL1: 1,
  4775. name: 2,
  4776. id: 3,
  4777. subL2: 4,
  4778. url: 5,
  4779. where: 6,
  4780. labelFields: 7,
  4781. processLabel: 8,
  4782. style: 9,
  4783. visibleAtZoom: 10,
  4784. labelsVisibleAtZoom: 11,
  4785. enabled: 12,
  4786. restrictTo: 13,
  4787. oneTimeAlert: 14,
  4788. };
  4789.  
  4790. const REQUIRED_FIELDS = Object.keys(FIELD_INDEXES);
  4791. let dataObjects = [];
  4792. /** @type {{ error: string | null }} */
  4793. const result = { error: null };
  4794.  
  4795. try {
  4796. const resp = await fetch(LAYER_DEF_URL);
  4797. const text = await resp.text();
  4798. const match = text.match(/google\.visualization\.Query\.setResponse\(([\s\S]+)\);/);
  4799. if (!match) {
  4800. result.error = 'Failed to parse Google Sheet data!';
  4801. logError(result.error);
  4802. return result;
  4803. }
  4804. const json = JSON.parse(match[1]);
  4805.  
  4806. const allRows = json.table.rows;
  4807.  
  4808. const firstDataIdx = allRows.findIndex((r) => r.c?.[0]?.v && r.c?.[1]?.v && r.c?.[2]?.v && r.c?.[3]?.v && typeof r.c[0].v === 'string' && typeof r.c[1].v === 'string');
  4809. if (firstDataIdx === -1) {
  4810. result.error = 'Could not auto-detect start of data rows!';
  4811. logError(result.error);
  4812. return result;
  4813. }
  4814.  
  4815. function rowToObj(row) {
  4816. const obj = {};
  4817. for (let key of REQUIRED_FIELDS) {
  4818. const idx = FIELD_INDEXES[key];
  4819. const cell = row.c && row.c[idx];
  4820. let value = cell && cell.v !== undefined && cell.v !== null ? cell.v : null;
  4821.  
  4822. // Coerce known numeric fields
  4823. if (key === 'visibleAtZoom' || key === 'labelsVisibleAtZoom') {
  4824. obj[key] = value !== null && value !== undefined && value !== '' ? Number(value) : null;
  4825. } else {
  4826. obj[key] = value;
  4827. }
  4828. }
  4829. return obj;
  4830. }
  4831.  
  4832. dataObjects = allRows
  4833. .slice(firstDataIdx)
  4834. .map(rowToObj)
  4835. .filter((obj) => obj.country && obj.subL1);
  4836.  
  4837. // --- VERSION CHECK ---
  4838. let minVersion = '';
  4839. if (dataObjects.length && /^\d+\.\d+\.\d+\.\d+$/.test(dataObjects[0].country)) {
  4840. minVersion = dataObjects[0].country;
  4841. dataObjects = dataObjects.slice(1);
  4842. }
  4843. if (typeof scriptVersion !== 'undefined' && minVersion && compareVersions(scriptVersion, minVersion) < 0) {
  4844. result.error = `Script must be updated to at least version ${minVersion} before layer definitions can be loaded.`;
  4845. logError(result.error);
  4846. }
  4847.  
  4848. const loadAll = (typeof isoCode === 'string' && isoCode.toUpperCase() === 'ALL') || (typeof regionCodes === 'string' && regionCodes.toUpperCase() === 'ALL');
  4849. if (!loadAll && (!regionCodes || typeof regionCodes.has !== 'function')) {
  4850. regionCodes = new Set();
  4851. }
  4852.  
  4853. if (!result.error) {
  4854. dataObjects.forEach((row) => {
  4855. // Normalize the enabled column: only 1 gets enabled, everything else (including blank) is 0
  4856. let enabledVal = (row.enabled || '').toString().trim().toLowerCase();
  4857. row.enabled = enabledVal === '1' ? 1 : 0;
  4858.  
  4859. if (row.enabled !== 1) return; // Skip rows not enabled
  4860.  
  4861. // It's now always 1 or 0 across all rows
  4862. const layerDef = { enabled: row.enabled };
  4863. let countryId = '',
  4864. subL1Upper = '';
  4865.  
  4866. REQUIRED_FIELDS.forEach((fldName) => {
  4867. let value = row[fldName];
  4868.  
  4869. // Always assign zoom fields as numbers
  4870. if (fldName === 'visibleAtZoom' || fldName === 'labelsVisibleAtZoom') {
  4871. layerDef[fldName] = value !== null && value !== undefined && value !== '' ? Number(value) : null;
  4872. return;
  4873. }
  4874.  
  4875. // Special array fields
  4876. if ((fldName === 'subL2' || fldName === 'labelFields') && typeof value === 'string') {
  4877. layerDef[fldName] = value.split(',').map((item) => item.trim());
  4878. return;
  4879. }
  4880.  
  4881. // Special label processor
  4882. if (fldName === 'processLabel' && typeof value === 'string') {
  4883. try {
  4884. layerDef[fldName] = ESTreeProcessor.compile(`function __$proc(){${value}} __$proc();`);
  4885. } catch (ex) {
  4886. layerDef.labelProcessingError = true;
  4887. logError(`Error loading label processing function for layer "${layerDef.id}".`, ex);
  4888. }
  4889. return;
  4890. }
  4891.  
  4892. // Style parsing
  4893. if (fldName === 'style' && typeof value === 'string') {
  4894. layerDef.isRoadLayer = value === 'roads';
  4895. if (!layerDef.isRoadLayer && typeof LAYER_STYLES !== 'undefined' && !LAYER_STYLES.hasOwnProperty(value)) {
  4896. try {
  4897. value = JSON.parse(value);
  4898. } catch (ex) {
  4899. logError(`Invalid style definition for layer "${layerDef.id}".`, ex);
  4900. }
  4901. }
  4902. layerDef[fldName] = value;
  4903. return;
  4904. }
  4905.  
  4906. // Uppercase helpers
  4907. if (fldName === 'country' && typeof value === 'string') countryId = value.toUpperCase();
  4908. if (fldName === 'subL1' && typeof value === 'string') {
  4909. subL1Upper = value.toUpperCase();
  4910. layerDef[fldName] = subL1Upper;
  4911. return;
  4912. }
  4913.  
  4914. // RestrictTo parser
  4915. if (fldName === 'restrictTo' && typeof value === 'string') {
  4916. try {
  4917. const values = value.split(',').map((v) => v.trim().toLowerCase());
  4918. layerDef.notAllowed = !values.some((entry) => {
  4919. const rankMatch = entry.match(/^r(\d)(\+am)?$/);
  4920. if (rankMatch) {
  4921. if (rankMatch[1] <= userInfo.rank + 1 && (!rankMatch[2] || userInfo.isAreaManager)) {
  4922. return true;
  4923. }
  4924. } else if (entry === 'am' && userInfo.isAreaManager) {
  4925. return true;
  4926. } else if (entry === userInfo.userName?.toLowerCase()) {
  4927. return true;
  4928. }
  4929. return false;
  4930. });
  4931. } catch (ex) {
  4932. logError(ex);
  4933. }
  4934. layerDef.restrictTo = value;
  4935. return;
  4936. }
  4937.  
  4938. if (fldName === 'labelFields' && (!value || typeof value !== 'string')) {
  4939. layerDef[fldName] = [''];
  4940. return;
  4941. }
  4942.  
  4943. // Assign all other fields where value is not null/undefined
  4944. if (value !== undefined && value !== null) {
  4945. layerDef[fldName] = value;
  4946. }
  4947. });
  4948.  
  4949. if (typeof layerDef.url === 'string') {
  4950. const url = layerDef.url;
  4951. if (/\/rest\/(services|Shared)\//i.test(url) || /\/MapServer(\/\d*)?$/i.test(url) || /\/gis\/rest\//i.test(url)) {
  4952. layerDef.platform = 'ArcGIS';
  4953. } else if (/\/resource\/[a-z0-9-]+$/i.test(url)) {
  4954. layerDef.platform = 'SocrataV2';
  4955. } else if (/\/api\/v3\/views\/[a-z0-9-]+/i.test(url)) {
  4956. layerDef.platform = 'SocrataV3';
  4957. } else {
  4958. layerDef.platform = 'Other';
  4959. }
  4960. } else {
  4961. layerDef.platform = 'Other';
  4962. }
  4963.  
  4964. let validSubL1 = false;
  4965. if (loadAll) {
  4966. layerDef.countrySubL1 = `${layerDef.country || ''}-${layerDef.subL1 || ''}`;
  4967. validSubL1 = true;
  4968. } else {
  4969. if (countryId === isoCode.toUpperCase() && subL1Upper) {
  4970. layerDef['countrySubL1'] = `${countryId}-${subL1Upper}`;
  4971. }
  4972. validSubL1 = regionCodes && (regionCodes.has(subL1Upper) || subL1Upper === isoCode.toUpperCase());
  4973. }
  4974. if (validSubL1 && !layerDef.notAllowed) {
  4975. const layerExists = typeof _gisLayers !== 'undefined' && _gisLayers.some((existingLayer) => existingLayer.id === layerDef.id);
  4976. if (!layerExists && typeof _gisLayers !== 'undefined') {
  4977. _gisLayers.push(layerDef);
  4978. }
  4979. }
  4980. });
  4981. }
  4982. } catch (err) {
  4983. result.error = `Spreadsheet call failed. ${err && err.message ? err.message : err}`;
  4984. logError(result.error, err);
  4985. }
  4986.  
  4987. if (!dataObjects.length) {
  4988. result.error = 'Spreadsheet was empty or did not return any valid rows.';
  4989. logError(result.error);
  4990. return result;
  4991. }
  4992. return result;
  4993. }
  4994.  
  4995. /**
  4996. * @param {string} shortcutId
  4997. * @param {string} description
  4998. * @param {Function} callback
  4999. */
  5000. function createShortcut(shortcutId, description, callback) {
  5001. let shortcutKeys = settings.shortcuts?.[shortcutId] ?? null;
  5002. if (shortcutKeys && sdk.Shortcuts.areShortcutKeysInUse({ shortcutKeys })) {
  5003. shortcutKeys = null;
  5004. }
  5005. sdk.Shortcuts.createShortcut({
  5006. shortcutId,
  5007. shortcutKeys, // may be null
  5008. description,
  5009. callback,
  5010. });
  5011. }
  5012.  
  5013. /**
  5014. * Initializes the GIS layers and related global state.
  5015. * On the first call, loads user info, settings, sets up shortcuts, GUI handlers, and event listeners.
  5016. * On every call, loads country and subdivision mappings and visible country data, updates the GUI and features.
  5017. *
  5018. * @async
  5019. * @param {boolean} [firstCall=true] - Whether this is the initial invocation (triggers full setup).
  5020. * @returns {Promise<void>} Resolves when initialization steps are complete.
  5021. */
  5022. async function init(firstCall = true) {
  5023. _gisLayers = [];
  5024. _whatsInView = {};
  5025. alreadyLoadedCountries.clear();
  5026. alreadyLoadedSubL1.clear();
  5027. countrySubdivisionMapping = {};
  5028.  
  5029. if (firstCall) {
  5030. userInfo = sdk.State.getUserInfo();
  5031. labelProcessingGlobalVariables.sdk = sdk;
  5032. loadSettingsFromStorage();
  5033. createShortcut('toggleHnsOnly', 'Toggle HN-only address labels (GIS Layers)', onAddressDisplayShortcutKey);
  5034. createShortcut('toggleEnabled', 'Toggle display of GIS Layers', onToggleGisLayersShortcutKey);
  5035. installPathFollowingLabels();
  5036. window.addEventListener('beforeunload', saveSettingsToStorage, false);
  5037. _layerSettingsDialog = new LayerSettingsDialog();
  5038. }
  5039. const t0 = performance.now();
  5040. try {
  5041. await buildCountrySubdivisionMapping();
  5042. await loadVisibleCountryData();
  5043. logDebug(`Loaded ${_gisLayers.length} layer definitions in ${Math.round(performance.now() - t0)} ms.`);
  5044. initGui(firstCall);
  5045. await fetchFeatures();
  5046. $('#gis-layers-refresh').removeClass('fa-spin').css({ cursor: 'pointer' });
  5047. logDebug('Initialized.');
  5048. } catch (err) {
  5049. logError(err);
  5050. }
  5051. }
  5052.  
  5053. init();
  5054.  
  5055. /**
  5056. * Enhances OpenLayers SVG renderer to support path-following text labels on line features.
  5057. *
  5058. * After calling this function, styles can support:
  5059. * - pathLabel: {String} text drawn along the path
  5060. * - pathLabelXOffset: {String} start offset, px or %, default "50%"
  5061. * - pathLabelYOffset: {Number} vertical offset from the path
  5062. * - pathLabelCurve: {String} smooth path text (empty for none)
  5063. * - pathLabelReadable: {String} reverse direction if needed for readability
  5064. * - All standard label/text style values (color, font, outline, etc.)
  5065. *
  5066. * Internally:
  5067. * - Adds `pathText` for text-on-path SVG creation
  5068. * - Overrides `setStyle` to support path label styling and outline/halo
  5069. * - Overrides `drawGeometry` and `eraseGeometry` to clean up text paths
  5070. *
  5071. * Call once during startup before rendering vector layers with path labels.
  5072. *
  5073. * @returns {void}
  5074. * @copyright Jean-Marc Viglino, 2015 (CeCILL-B / Beerware License)
  5075. * @see http://www.cecill.info/
  5076. * @see http://en.wikipedia.org/wiki/Beerware
  5077. */
  5078. function installPathFollowingLabels() {
  5079. /**
  5080. * Removes a child element with the specified id from a DOM node.
  5081. *
  5082. * Handles both standard and older browser DOM APIs.
  5083. *
  5084. * @param {Node} node - The parent DOM node.
  5085. * @param {string} id - The id of the child element to remove.
  5086. * @returns {void}
  5087. */
  5088. function removeChildById(node, id) {
  5089. if (node.querySelector) {
  5090. var c = node.querySelector('#' + id);
  5091. if (c) node.removeChild(c);
  5092. return;
  5093. }
  5094. // For old browsers
  5095. var c = node.childNodes;
  5096. if (c)
  5097. for (var i = 0; i < c.length; i++) {
  5098. if (c[i].id === id) {
  5099. node.removeChild(c[i]);
  5100. return;
  5101. }
  5102. }
  5103. }
  5104.  
  5105. var setStyle = OpenLayers.Renderer.SVG.prototype.setStyle;
  5106. OpenLayers.Renderer.SVG.LABEL_STARTOFFSET = { l: '0%', r: '100%', m: '50%' };
  5107.  
  5108. /**
  5109. * Renders text as an SVG textPath following a geometry path.
  5110. *
  5111. * Applies OpenLayers/extra path label style options (see installPathFollowingLabels).
  5112. *
  5113. * @param {SVGElement} node - The SVG node representing the feature.
  5114. * @param {Object} style - Style object.
  5115. * @param {string} suffix - Suffix for unique element IDs.
  5116. * @returns {void}
  5117. */
  5118. OpenLayers.Renderer.SVG.prototype.pathText = function (node, style, suffix) {
  5119. var label = this.nodeFactory(null, 'text');
  5120. label.setAttribute('id', node._featureId + '_' + suffix);
  5121. if (style.fontColor) label.setAttributeNS(null, 'fill', style.fontColor);
  5122. if (style.fontStrokeColor) label.setAttributeNS(null, 'stroke', style.fontStrokeColor);
  5123. if (style.fontStrokeWidth) label.setAttributeNS(null, 'stroke-width', style.fontStrokeWidth);
  5124. if (style.fontOpacity) label.setAttributeNS(null, 'opacity', style.fontOpacity);
  5125. if (style.fontFamily) label.setAttributeNS(null, 'font-family', style.fontFamily);
  5126. if (style.fontSize) label.setAttributeNS(null, 'font-size', style.fontSize);
  5127. if (style.fontWeight) label.setAttributeNS(null, 'font-weight', style.fontWeight);
  5128. if (style.fontStyle) label.setAttributeNS(null, 'font-style', style.fontStyle);
  5129. if (style.labelSelect === true) {
  5130. label.setAttributeNS(null, 'pointer-events', 'visible');
  5131. label._featureId = node._featureId;
  5132. } else {
  5133. label.setAttributeNS(null, 'pointer-events', 'none');
  5134. }
  5135.  
  5136. /**
  5137. * Parses a path string into an array of x/y points, optionally reversing for readability.
  5138. *
  5139. * @param {string} pathStr - The path string (comma-separated numbers).
  5140. * @param {boolean|string} readeable - If true, reverse the point order (for text readability).
  5141. * @returns {Array<{x: number, y: number}>} Array of point objects.
  5142. */
  5143. function getpath(pathStr, readeable) {
  5144. var npath = pathStr.split(',');
  5145. var pts = [];
  5146. if (!readeable || Number(npath[0]) - Number(npath[npath.length - 2]) < 0) {
  5147. while (npath.length) pts.push({ x: Number(npath.shift()), y: Number(npath.shift()) });
  5148. } else {
  5149. while (npath.length) pts.unshift({ x: Number(npath.shift()), y: Number(npath.shift()) });
  5150. }
  5151. return pts;
  5152. }
  5153.  
  5154. var path = this.nodeFactory(null, 'path');
  5155. var tpid = node._featureId + '_t' + suffix;
  5156. var tpath = node.getAttribute('points');
  5157. if (style.pathLabelCurve) {
  5158. var pts = getpath(tpath, style.pathLabelReadable);
  5159. var p = pts[0].x + ' ' + pts[0].y;
  5160. var dx, dy, s1, s2;
  5161. dx = (pts[0].x - pts[1].x) / 4;
  5162. dy = (pts[0].y - pts[1].y) / 4;
  5163. for (var i = 1; i < pts.length - 1; i++) {
  5164. p += ' C ' + (pts[i - 1].x - dx) + ' ' + (pts[i - 1].y - dy);
  5165. dx = (pts[i - 1].x - pts[i + 1].x) / 4;
  5166. dy = (pts[i - 1].y - pts[i + 1].y) / 4;
  5167. s1 = Math.sqrt(Math.pow(pts[i - 1].x - pts[i].x, 2) + Math.pow(pts[i - 1].y - pts[i].y, 2));
  5168. s2 = Math.sqrt(Math.pow(pts[i + 1].x - pts[i].x, 2) + Math.pow(pts[i + 1].y - pts[i].y, 2));
  5169. p += ' ' + (pts[i].x + (s1 * dx) / s2) + ' ' + (pts[i].y + (s1 * dy) / s2);
  5170. dx *= s2 / s1;
  5171. dy *= s2 / s1;
  5172. p += ' ' + pts[i].x + ' ' + pts[i].y;
  5173. }
  5174. p += ' C ' + (pts[i - 1].x - dx) + ' ' + (pts[i - 1].y - dy);
  5175. dx = (pts[i - 1].x - pts[i].x) / 4;
  5176. dy = (pts[i - 1].y - pts[i].y) / 4;
  5177. p += ' ' + (pts[i].x + dx) + ' ' + (pts[i].y + dy);
  5178. p += ' ' + pts[i].x + ' ' + pts[i].y;
  5179.  
  5180. path.setAttribute('d', 'M ' + p);
  5181. } else {
  5182. if (style.pathLabelReadable) {
  5183. var pts = getpath(tpath, style.pathLabelReadable);
  5184. var p = '';
  5185. for (var i = 0; i < pts.length; i++) p += ' ' + pts[i].x + ' ' + pts[i].y;
  5186. path.setAttribute('d', 'M ' + p);
  5187. } else path.setAttribute('d', 'M ' + tpath);
  5188. }
  5189. path.setAttribute('id', tpid);
  5190.  
  5191. var defs = this.createDefs();
  5192. removeChildById(defs, tpid);
  5193. defs.appendChild(path);
  5194.  
  5195. var textPath = this.nodeFactory(null, 'textPath');
  5196. textPath.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#' + tpid);
  5197. var align = style.labelAlign || OpenLayers.Renderer.defaultSymbolizer.labelAlign;
  5198. label.setAttributeNS(null, 'text-anchor', OpenLayers.Renderer.SVG.LABEL_ALIGN[align[0]] || 'middle');
  5199. textPath.setAttribute('startOffset', style.pathLabelXOffset || OpenLayers.Renderer.SVG.LABEL_STARTOFFSET[align[0]] || '50%');
  5200. label.setAttributeNS(null, 'dominant-baseline', OpenLayers.Renderer.SVG.LABEL_ALIGN[align[1]] || 'central');
  5201. if (style.pathLabelYOffset) label.setAttribute('dy', style.pathLabelYOffset);
  5202.  
  5203. textPath.textContent = style.pathLabel;
  5204. label.appendChild(textPath);
  5205.  
  5206. removeChildById(this.textRoot, node._featureId + '_' + suffix);
  5207. this.textRoot.appendChild(label);
  5208. };
  5209.  
  5210. /**
  5211. * Sets style attributes on an SVG node, adding support for text labels following paths.
  5212. *
  5213. * If the geometry is a line and the style includes path label options,
  5214. * draws the label (and optional outline/halo) along the path.
  5215. *
  5216. * @param {SVGElement} node - The SVG node.
  5217. * @param {Object} style - Style object, can include path label options.
  5218. * @param {Object} [options] - Additional options (isFilled, isStroked, etc).
  5219. * @returns {SVGElement} The styled SVG node.
  5220. */
  5221. OpenLayers.Renderer.SVG.prototype.setStyle = function (node, style, options) {
  5222. if (node._geometryClass === 'OpenLayers.Geometry.LineString' && style.pathLabel) {
  5223. var drawOutline = !!style.labelOutlineWidth;
  5224. // First draw text in halo color and size and overlay the
  5225. // normal text afterwards
  5226. if (drawOutline) {
  5227. var outlineStyle = OpenLayers.Util.extend({}, style);
  5228. outlineStyle.fontColor = outlineStyle.labelOutlineColor;
  5229. outlineStyle.fontStrokeColor = outlineStyle.labelOutlineColor;
  5230. outlineStyle.fontStrokeWidth = style.labelOutlineWidth;
  5231. if (style.labelOutlineOpacity) outlineStyle.fontOpacity = style.labelOutlineOpacity;
  5232. delete outlineStyle.labelOutlineWidth;
  5233. this.pathText(node, outlineStyle, 'txtpath0');
  5234. }
  5235. this.pathText(node, style, 'txtpath');
  5236. setStyle.apply(this, arguments);
  5237. } else setStyle.apply(this, arguments);
  5238. return node;
  5239. };
  5240.  
  5241. var drawGeometry = OpenLayers.Renderer.SVG.prototype.drawGeometry;
  5242.  
  5243. /**
  5244. * Draws a geometry, removing textPaths if geometry was not fully rendered.
  5245. *
  5246. * @param {OpenLayers.Geometry} geometry - Geometry to render.
  5247. * @param {Object} style - Style options.
  5248. * @param {string} id - Feature ID.
  5249. * @returns {boolean|null} True if geometry is drawn, null if incomplete, false otherwise.
  5250. */
  5251. OpenLayers.Renderer.SVG.prototype.drawGeometry = function (geometry, style, id) {
  5252. var rendered = drawGeometry.apply(this, arguments);
  5253. if (rendered === false) {
  5254. removeChildById(this.textRoot, id + '_txtpath');
  5255. removeChildById(this.textRoot, id + '_txtpath0');
  5256. }
  5257. return rendered;
  5258. };
  5259.  
  5260. var eraseGeometry = OpenLayers.Renderer.SVG.prototype.eraseGeometry;
  5261.  
  5262. /**
  5263. * Erases geometry from the renderer and removes associated textPath labels from the DOM.
  5264. *
  5265. * @param {OpenLayers.Geometry} geometry - Geometry to erase.
  5266. * @param {string} featureId - Feature ID.
  5267. * @returns {void}
  5268. */
  5269. OpenLayers.Renderer.SVG.prototype.eraseGeometry = function (geometry, featureId) {
  5270. eraseGeometry.apply(this, arguments);
  5271. removeChildById(this.textRoot, featureId + '_txtpath');
  5272. removeChildById(this.textRoot, featureId + '_txtpath0');
  5273. };
  5274. }
  5275. })();