/* eslint-disable camelcase */
/* eslint-disable brace-style, curly, nonblock-statement-body-position, no-template-curly-in-string, func-names */
// ==UserScript==
// @name WME GIS Layers
// @namespace https://greasyfork.org/users/45389
// @version 2025.08.10.00
// @description Adds GIS layers in WME
// @author MapOMatic / JS55CT
// @match *://*.waze.com/*editor*
// @exclude *://*.waze.com/user/editor*
// @exclude *://*.waze.com/editor/sdk/*
// @require https://greasyfork.org/scripts/24851-wazewrap/code/WazeWrap.js
// @require https://cdn.jsdelivr.net/npm/@turf/turf@7/turf.min.js
// @require https://update.greasyfork.org/scripts/506614/1441195/ESTreeProcessor.js
// @require https://update.greasyfork.org/scripts/509664/WME%20Utils%20-%20Bootstrap.js
// @require https://update.greasyfork.org/scripts/516445/1480246/Make%20GM%20xhr%20more%20parallel%20again.js
// @require https://update.greasyfork.org/scripts/542477/1623802/wmeGisLBBOX.js
// @connect greasyfork.org
// @connect github.io
// @grant GM_xmlhttpRequest
// @grant GM_info
// @grant GM_setClipboard
// @license GNU GPLv3
// @contributionURL https://github.com/WazeDev/Thank-The-Authors
// @connect *
// @connect tigerweb.geo.census.gov
// @connect 136.234.13.165
// @connect 216.167.160.20
// @connect 35.172.145.31
// @connect 52.37.30.30
// @connect 54.213.14.253
// @connect 72.10.206.73
// @connect a2maps.a2gov.org
// @connect adairgis.integritygis.com
// @connect agis.charlottecountyfl.gov
// @connect ago.clarkcountyohio.gov
// @connect agomaps.larimer.org
// @connect ags.agdmaps.com
// @connect ags.bhamaps.com
// @connect ags.kitsap.gov
// @connect ags.myokaloosa.com
// @connect ags.roseville.ca.us
// @connect ags1.wgxtreme.com
// @connect ags2maps.srcity.org
// @connect ags3.scgov.net
// @connect aldotgis.dot.state.al.us
// @connect alleganygis.allconet.org
// @connect alphagis.alpharetta.ga.us
// @connect andrewgis.integritygis.com
// @connect anrmaps.vermont.gov
// @connect ansoncountygis.com
// @connect api.milton.ca
// @connect apnsgis1.apsu.edu
// @connect apnsgis4.apsu.edu
// @connect app.mdt.mt.gov
// @connect apps.alamance-nc.com
// @connect apps.fs.usda.gov
// @connect apps.lickingcounty.gov
// @connect apps.saltlakecounty.gov
// @connect apps.vernoncounty.org
// @connect apps.wyoroad.info
// @connect arcgis-morrowarcgis-1015369042.us-east-1.elb.amazonaws.com
// @connect arcgis-web.chinohills.org
// @connect arcgis.atlantaregional.com
// @connect arcgis.c3gov.com
// @connect arcgis.cityofcapegirardeau.org
// @connect arcgis.cityofwatsonville.org
// @connect arcgis.clearfieldco.org
// @connect arcgis.co.beltrami.mn.us
// @connect arcgis.co.henry.ga.us
// @connect arcgis.co.lancaster.pa.us
// @connect arcgis.forneytx.gov
// @connect arcgis.gis.lacounty.gov
// @connect arcgis.kingsporttn.gov
// @connect arcgis.leaguecitytx.gov
// @connect arcgis.lewiscountywa.gov
// @connect arcgis.mobile311.com
// @connect arcgis.racinecounty.com
// @connect arcgis.tampagov.net
// @connect arcgis.tuscco.com
// @connect arcgis.vgsi.com
// @connect arcgis.water.nv.gov
// @connect arcgis.waxahachie.com
// @connect arcgis.yumacountyaz.gov
// @connect arcgis4.roktech.net
// @connect arcgis5.roktech.net
// @connect arcgisce2.co.valencia.nm.us
// @connect arcgisserver.digital.mass.gov
// @connect arcgisserver.lincolncounty.org
// @connect arcgisserver.maine.gov
// @connect arcgisserver2.morpc.org
// @connect arcgissrv.cityofbartlesville.org
// @connect arcgiswap01.ci.temple.tx.us
// @connect arcgisweb.carteretcountync.gov
// @connect arcgisweb.countyofnewaygo.com
// @connect arcgisweb.welland.ca
// @connect arcmobile.co.albany.wy.us
// @connect arcportal.florenceco.org
// @connect arcserv.co.washington.ar.us
// @connect arcserver.madisoncountyky.us
// @connect arcserver2.oconeesc.com
// @connect arcweb.hcad.org
// @connect ardmoregis.ardmorecity.org
// @connect arlgis.arlingtonva.us
// @connect atchisongis.integritygis.com
// @connect atlas.co.chelan.wa.us
// @connect atlas.geoportalmaps.com
// @connect atlas.unioncountync.gov
// @connect audraingis.integritygis.com
// @connect batesgis.integritygis.com
// @connect bcgis.baltimorecountymd.gov
// @connect bcgis.brunswickcountync.gov
// @connect bcgishub.broward.org
// @connect bcmaps.bradfordco.org
// @connect bentongis.integritygis.com
// @connect biamaps.geoplatform.gov
// @connect bocagis.ci.boca-raton.fl.us
// @connect bonneville.esriemcs.com
// @connect bpagis.bossierparish.org
// @connect bryangis.bryan-county.org
// @connect buchanangis.integritygis.com
// @connect butlergis.integritygis.com
// @connect c39gisserver.co.richland.nd.us
// @connect ca.dep.state.fl.us
// @connect cagisonline.hamilton-co.org
// @connect calmaps.co.calumet.wi.us
// @connect caltrans-gis.dot.ca.gov
// @connect cama.shelbycountyauditors.com
// @connect camdengis.integritygis.com
// @connect carto.nationalmap.gov
// @connect cassweb.casscountymn.gov
// @connect cceo.co.comal.tx.us
// @connect ccmap.cccounty.us
// @connect cecilmaps.org
// @connect charitongis.integritygis.com
// @connect christiangis.integritygis.com
// @connect cloud.longviewtexas.gov
// @connect cloudgis.bonnercountyid.gov
// @connect co.knox.il.us
// @connect coagisweb.cabq.gov
// @connect com.blountgis.org
// @connect concordgis.ci.concord.ca.us
// @connect conservationgis.alabama.gov
// @connect coopergis.integritygis.com
// @connect covgis.cityofvacaville.com
// @connect coweta-gis-web.coweta.ga.us
// @connect cowlitzgis.net
// @connect crgis.cedar-rapids.org
// @connect cteco.uconn.edu
// @connect cty-gis-web.co.humboldt.ca.us
// @connect cw.townofclaytonnc.org
// @connect dadegis.integritygis.com
// @connect dallasgis.integritygis.com
// @connect data.calgary.ca
// @connect data.cityofchicago.org
// @connect data.ct.gov
// @connect data.edmonton.ca
// @connect data.novascotia.ca
// @connect data.wsdot.wa.gov
// @connect data1.digitaldataservices.com
// @connect dc-web-2.co.douglas.mn.us
// @connect dcgis.dekalbcountyga.gov
// @connect dcimapapps.countyofdane.com
// @connect dekalbgis.integritygis.com
// @connect delta.co.clatsop.or.us
// @connect dev.wilsonvillemaps.com
// @connect doniphangis.integritygis.com
// @connect dotapp9.dot.state.mn.us
// @connect douglasgis.integritygis.com
// @connect dtdapps.coloradodot.info
// @connect dungis.dunwoodyga.gov
// @connect dunklingis.integritygis.com
// @connect egis.baltimorecity.gov
// @connect egis.pinellas.gov
// @connect elb.elevatemaps.io
// @connect emapsplus.com
// @connect enigma.accgov.com
// @connect enterprise.firstmap.delaware.gov
// @connect eoc.franklin-gov.com
// @connect epv.ci.juneau.ak.us
// @connect eservices.co.crook.or.us
// @connect essex-gis.co.essex.ny.us
// @connect explore.opelika-al.gov
// @connect fcgis.franklincountypa.gov
// @connect feature.geographic.texas.gov
// @connect feature.tnris.org
// @connect fieldstone.orangecountync.gov
// @connect fragis.fra.dot.gov
// @connect fremontgis.com
// @connect gasconadegis.integritygis.com
// @connect gateway.maps.rlid.org
// @connect gcgis.guilfordcountync.gov
// @connect geaugarealink.co.geauga.oh.us
// @connect geo.co.butler.pa.us
// @connect geo.co.harrison.ms.us
// @connect geo.dentoncad.com
// @connect geo.forsythco.com
// @connect geo.friscotexas.gov
// @connect geo.lloydminster.ca
// @connect geo.oit.ohio.gov
// @connect geo.sandag.org
// @connect geo.sanjoseca.gov
// @connect geo.skagitcountywa.gov
// @connect geo.statcan.gc.ca
// @connect geo.tompkins-co.org
// @connect geo.vbgov.com
// @connect geo1.oit.ohio.gov
// @connect geo2.co.dodge.wi.us
// @connect geodata.hawaii.gov
// @connect geodata.md.gov
// @connect geodata.sarpy.com
// @connect geodataportal.net
// @connect geonb.snb.ca
// @connect geoportal.kelowna.ca
// @connect geopower.jws.com
// @connect geospatial.alberta.ca
// @connect geoweb.martin.fl.us
// @connect geoweb02.ci.richmond.ca.us
// @connect gis-2.warrencountyny.gov
// @connect gis-erd-der.gnb.ca
// @connect gis-server.co.becker.mn.us
// @connect gis-server.co.montezuma.co.us
// @connect gis.aacounty.org
// @connect gis.abilenetx.com
// @connect gis.adamscounty.org
// @connect gis.addisontx.gov
// @connect gis.aecomonline.net
// @connect gis.allegancounty.org
// @connect gis.allencountyohio.com
// @connect gis.apachejunctionaz.gov
// @connect gis.arapahoegov.com
// @connect gis.arkansas.gov
// @connect gis.ashecountygov.com
// @connect gis.ashevillenc.gov
// @connect gis.atlantaga.gov
// @connect gis.auburnalabama.org
// @connect gis.auglaizecounty.org
// @connect gis.azdot.gov
// @connect gis.bakersfieldcity.us
// @connect gis.baycountyfl.gov
// @connect gis.beaufortcountysc.gov
// @connect gis.beaumonttexas.gov
// @connect gis.bentoncountyar.gov
// @connect gis.berkeleycountysc.gov
// @connect gis.bigstonecounty.gov
// @connect gis.bladenco.org
// @connect gis.blairco.org
// @connect gis.blm.gov
// @connect gis.blueearthcountymn.gov
// @connect gis.bransonmo.gov
// @connect gis.brevardfl.gov
// @connect gis.browncountywi.gov
// @connect gis.buncombecounty.org
// @connect gis.burkenc.org
// @connect gis.burleighco.com
// @connect gis.buttecounty.net
// @connect gis.caldwellcountync.org
// @connect gis.calhouncounty.org
// @connect gis.campbellca.gov
// @connect gis.campbellcountywy.gov
// @connect gis.carboncounty.com
// @connect gis.cayugacounty.us
// @connect gis.cccounty.us
// @connect gis.ccgisonline.com
// @connect gis.ccpa.net
// @connect gis.cedarfalls.com
// @connect gis.cedarhilltx.com
// @connect gis.cherokeega.com
// @connect gis.chestermere.ca
// @connect gis.chippewa.mn
// @connect gis.chisagocountymn.gov
// @connect gis.ci.janesville.wi.us
// @connect gis.ci.mcminnville.or.us
// @connect gis.ci.waco.tx.us
// @connect gis.citruspa.org
// @connect gis.cityofaikensc.gov
// @connect gis.cityofberkeley.info
// @connect gis.cityofboston.gov
// @connect gis.cityofdenton.com
// @connect gis.cityofirvine.org
// @connect gis.cityofmiddletown.com
// @connect gis.cityofmoore.com
// @connect gis.cityofsanmateo.org
// @connect gis.cityofwestsacramento.org
// @connect gis.clevelandtn.gov
// @connect gis.cmpdd.org
// @connect gis.co.benton.or.us
// @connect gis.co.berks.pa.us
// @connect gis.co.carlton.mn.us
// @connect gis.co.carver.mn.us
// @connect gis.co.clarion.pa.us
// @connect gis.co.cumberland.nc.us
// @connect gis.co.door.wi.us
// @connect gis.co.douglas.or.us
// @connect gis.co.eau-claire.wi.us
// @connect gis.co.fairfield.oh.us
// @connect gis.co.fillmore.mn.us
// @connect gis.co.grand.co.us
// @connect gis.co.grant.mn.us
// @connect gis.co.grant.wi.gov
// @connect gis.co.green-lake.wi.us
// @connect gis.co.hubbard.mn.us
// @connect gis.co.isanti.mn.us
// @connect gis.co.josephine.or.us
// @connect gis.co.kittitas.wa.us
// @connect gis.co.linn.or.us
// @connect gis.co.mille-lacs.mn.us
// @connect gis.co.nezperce.id.us
// @connect gis.co.oneida.wi.us
// @connect gis.co.pepin.wi.us
// @connect gis.co.pierce.wi.us
// @connect gis.co.polk.mn.us
// @connect gis.co.richland.wi.us
// @connect gis.co.roseau.mn.us
// @connect gis.co.sangamon.il.us
// @connect gis.co.sauk.wi.us
// @connect gis.co.sherburne.mn.us
// @connect gis.co.stearns.mn.us
// @connect gis.co.stevens.mn.us
// @connect gis.co.tuscarawas.oh.us
// @connect gis.co.wadena.mn.us
// @connect gis.co.waseca.mn.us
// @connect gis.co.waushara.wi.us
// @connect gis.co.ym.mn.gov
// @connect gis.colorado.gov
// @connect gis.coloradosprings.gov
// @connect gis.columbiacountyga.gov
// @connect gis.columbiacountymaps.com
// @connect gis.columbiasc.gov
// @connect gis.columbusga.org
// @connect gis.concordnh.gov
// @connect gis.cookeville-tn.org
// @connect gis.corvallisoregon.gov
// @connect gis.cosb.us
// @connect gis.countyofriverside.us
// @connect gis.cowleycounty.org
// @connect gis.cranstonri.org
// @connect gis.cravencountync.gov
// @connect gis.crcog.org
// @connect gis.crookcounty.wy.gov
// @connect gis.crowwing.us
// @connect gis.cstx.gov
// @connect gis.cuyahogacounty.us
// @connect gis.danville-va.gov
// @connect gis.dauphincounty.org
// @connect gis.deerparktx.gov
// @connect gis.dekalbcountyga.gov
// @connect gis.delcopa.gov
// @connect gis.dentoncounty.gov
// @connect gis.districtiii.org
// @connect gis.dogis.org
// @connect gis.donaanacounty.org
// @connect gis.dot.nv.gov
// @connect gis.dot.state.oh.us
// @connect gis.douglascountyks.org
// @connect gis.dubuquecounty.us
// @connect gis.dupageco.org
// @connect gis.duplincountync.com
// @connect gis.dutchessny.gov
// @connect gis.eastgreenwichri.com
// @connect gis.edgecombecountync.gov
// @connect gis.edmondok.gov
// @connect gis.elkocountynv.net
// @connect gis.elpasotexas.gov
// @connect gis.emmetcounty.org
// @connect gis.eriecountypa.gov
// @connect gis.fortlauderdale.gov
// @connect gis.franklincountyohio.gov
// @connect gis.fultoncountyoh.com
// @connect gis.fwb.org
// @connect gis.fwp.mt.gov
// @connect gis.gallatin.mt.gov
// @connect gis.gallupnm.us
// @connect gis.garrettcounty.org
// @connect gis.gastongov.com
// @connect gis.gcrc.org
// @connect gis.gilacountyaz.gov
// @connect gis.gocolumbiamo.com
// @connect gis.goshencounty.org
// @connect gis.gptx.org
// @connect gis.grandcountyutah.net
// @connect gis.greenecountyohio.gov
// @connect gis.greenegovernment.com
// @connect gis.greensboro-nc.gov
// @connect gis.gscplanning.com
// @connect gis.haldimandcounty.ca
// @connect gis.hardeecounty.net
// @connect gis.harnett.org
// @connect gis.hartford.gov
// @connect gis.hawaiicounty.gov
// @connect gis.hcpafl.org
// @connect gis.hennepin.us
// @connect gis.huntingtonbeachca.gov
// @connect gis.iberiagov.net
// @connect gis.indot.in.gov
// @connect gis.interdev.com
// @connect gis.iowadot.gov
// @connect gis.itd.idaho.gov
// @connect gis.jacksonnc.org
// @connect gis.jccal.org
// @connect gis.johnson-county.com
// @connect gis.johnsoncitytn.org
// @connect gis.kalamazoocity.org
// @connect gis.kanawhacountyassessor.com
// @connect gis.kaufmancounty.net
// @connect gis.kcgov.us
// @connect gis.kcmn.us
// @connect gis.kentcountyde.gov
// @connect gis.kentcountymi.gov
// @connect gis.kleinfelder.com
// @connect gis.lacrossecounty.org
// @connect gis.lafayettecountywi.org
// @connect gis.lakecountyfl.gov
// @connect gis.lakecountyohio.gov
// @connect gis.lapazcountyaz.org
// @connect gis.laplata.co.us
// @connect gis.lasallecounty.org
// @connect gis.latah.id.us
// @connect gis.leecountyil.com
// @connect gis.lehighcounty.org
// @connect gis.leoc.net
// @connect gis.lethbridge.ca
// @connect gis.lincoln.ne.gov
// @connect gis.littleelm.org
// @connect gis.livingstoncounty.us
// @connect gis.lja.com
// @connect gis.lojic.org
// @connect gis.losalamosnm.us
// @connect gis.luzernecounty.org
// @connect gis.lyco.org
// @connect gis.lyon-county.org
// @connect gis.macombgov.org
// @connect gis.maconnc.org
// @connect gis.maderacounty.com
// @connect gis.marinpublic.com
// @connect gis.marionfl.org
// @connect gis.masoncountywa.gov
// @connect gis.massdot.state.ma.us
// @connect gis.mbakerintl.com
// @connect gis.mcgtn.org
// @connect gis.mckeancountypa.gov
// @connect gis.mcohio.org
// @connect gis.mendocinocounty.org
// @connect gis.mercercountypa.gov
// @connect gis.mesaaz.gov
// @connect gis.mifflincountypa.gov
// @connect gis.minnehahacounty.org
// @connect gis.miottawa.org
// @connect gis.missoulacounty.us
// @connect gis.modestogov.com
// @connect gis.mono.ca.gov
// @connect gis.montgomeryal.gov
// @connect gis.moorecountync.gov
// @connect gis.moosejaw.ca
// @connect gis.mytoddcounty.com
// @connect gis.napa.ca.gov
// @connect gis.nashcountync.gov
// @connect gis.nassaucountyny.gov
// @connect gis.nccde.org
// @connect gis.ne.gov
// @connect gis.neccog.org
// @connect gis.newedgeservices.com
// @connect gis.newhavenct.gov
// @connect gis.nhcgov.com
// @connect gis.niagaracounty.com
// @connect gis.nola.gov
// @connect gis.norrycopa.net
// @connect gis.northamptoncounty.org
// @connect gis.odot.state.or.us
// @connect gis.ohiodnr.gov
// @connect gis.okc.gov
// @connect gis.orangecountygov.com
// @connect gis.orangecountyva.gov
// @connect gis.orrsc.com
// @connect gis.osceola.org
// @connect gis.outagamie.org
// @connect gis.owensboro.org
// @connect gis.pandai.com
// @connect gis.pendercountync.gov
// @connect gis.pendoreilleco.org
// @connect gis.penndot.gov
// @connect gis.penndot.pa.gov
// @connect gis.peoriacounty.gov
// @connect gis.personcountync.gov
// @connect gis.pgatlas.com
// @connect gis.pikepa.org
// @connect gis.pinal.gov
// @connect gis.pittcountync.gov
// @connect gis.pittsburgca.gov
// @connect gis.polk-county.net
// @connect gis.popecountymn.gov
// @connect gis.port-orange.org
// @connect gis.pottcounty-ia.gov
// @connect gis.princeedwardisland.ca
// @connect gis.putnam-fl.com
// @connect gis.qac.org
// @connect gis.qualicumbeach.com
// @connect gis.randolphcountync.gov
// @connect gis.rapides911.org
// @connect gis.rcgov.org
// @connect gis.rdck.bc.ca
// @connect gis.renvillecountymn.com
// @connect gis.rileycountyks.gov
// @connect gis.rocklin.ca.us
// @connect gis.rowancountync.gov
// @connect gis.rrnm.gov
// @connect gis.rtcsnv.com
// @connect gis.rutherfordcountync.gov
// @connect gis.sanjuanco.com
// @connect gis.santa-clarita.com
// @connect gis.santacruzcounty.us
// @connect gis.santamonica.gov
// @connect gis.saskatchewan.ca
// @connect gis.sawyerwi.org
// @connect gis.sccwi.gov
// @connect gis.shastacounty.gov
// @connect gis.sheboygancounty.com
// @connect gis.shelbycountytn.gov
// @connect gis.showmeboone.com
// @connect gis.siouxfalls.gov
// @connect gis.slocounty.ca.gov
// @connect gis.sncoapps.us
// @connect gis.southkingstownri.com
// @connect gis.steele.mn
// @connect gis.stlouiscountymn.gov
// @connect gis.sullivanny.us
// @connect gis.sumtercountyfl.gov
// @connect gis.surryinfo.net
// @connect gis.talbotdes.org
// @connect gis.tazewell.com
// @connect gis.texoma.cog.tx.us
// @connect gis.thecolonytx.gov
// @connect gis.thomsonreuters.com
// @connect gis.transportation.wv.gov
// @connect gis.transylvaniacounty.org
// @connect gis.traviscountytx.gov
// @connect gis.tularecounty.ca.gov
// @connect gis.ucdavis.edu
// @connect gis.ulstercountyny.gov
// @connect gis.vernon-ct.gov
// @connect gis.victorvilleca.gov
// @connect gis.warrensburg-mo.com
// @connect gis.washingtoncountyny.gov
// @connect gis.watertownwi.gov
// @connect gis.waukesha-wi.gov
// @connect gis.waukeshacounty.gov
// @connect gis.weatherfordtx.gov
// @connect gis.westmorelandcountypa.gov
// @connect gis.westplains.net
// @connect gis.whatcomcounty.us
// @connect gis.whitfieldcountyga.com
// @connect gis.wilco.org
// @connect gis.wilkescounty.net
// @connect gis.willcountyillinois.com
// @connect gis.wilson-co.com
// @connect gis.wilsonnc.org
// @connect gis.wiu.edu
// @connect gis.woodcountywi.gov
// @connect gis.worldviewsolutions.com
// @connect gis.wyo.gov
// @connect gis.yadkincountync.gov
// @connect gis.yanceycountync.org
// @connect gis.yavapaiaz.gov
// @connect gis.yellowstonecountymt.gov
// @connect gis.yolocounty.gov
// @connect gis.yolocounty.org
// @connect gis.yuba.org
// @connect gis1.acimap.us
// @connect gis1.georgetowncountysc.org
// @connect gis1.hamiltoncounty.in.gov
// @connect gis11.cama.io
// @connect gis11.services.ncdot.gov
// @connect gis12.cookcountyil.gov
// @connect gis2.arlingtontx.gov
// @connect gis2.co.dakota.mn.us
// @connect gis2.co.marathon.wi.us
// @connect gis2.co.ozaukee.wi.us
// @connect gis2.erie.gov
// @connect gis2.gworks.com
// @connect gis2.idaho.gov
// @connect gis2.lawrenceks.org
// @connect gis2.orangeburgcounty.org
// @connect gis2.sandyspringsga.gov
// @connect gis2.totaland.com
// @connect gis21svweb.lincolnparish.org
// @connect gis3.cdmsmithgis.com
// @connect gis3.cmpdd.org
// @connect gis3.gwinnettcounty.com
// @connect gis3.gworks.com
// @connect gis3.montgomerycountymd.gov
// @connect gis3.richmondnc.com
// @connect gis4.montgomerycountymd.gov
// @connect gisago-qa.mcgi.state.mi.us
// @connect gisago.mcgi.state.mi.us
// @connect gisapp.adcogov.org
// @connect gisapp.mahoningcountyoh.gov
// @connect gisapps.cityofchicago.org
// @connect gisapps.rileycountyks.gov
// @connect gisapps.wicomicocounty.org
// @connect gisapps1.mapoakland.com
// @connect gisarcweb.jeffersoncountywv.org
// @connect gisccapps.charlestoncounty.org
// @connect gisdata.alleghenycounty.us
// @connect gisdata.dot.ca.gov
// @connect gisdata.in.gov
// @connect gisdata.jeffersoncountyoh.com
// @connect gisdata.kingcounty.gov
// @connect gisdata.pandai.com
// @connect gisdata.pima.gov
// @connect gisdata.seattle.gov
// @connect gisdemo1.cdmsmith.com
// @connect gisdemo2.cdmsmith.com
// @connect gisentapp01.highpointnc.gov
// @connect gisext.lincoln.ne.gov
// @connect gisext.saskatoon.ca
// @connect gisext2.cnv.org
// @connect gishost.cdmsmithgis.com
// @connect gisinfo.co.portage.wi.gov
// @connect gisinfo.co.walworth.wi.us
// @connect gisinfo.lawrencevillega.org
// @connect gismap.augustaga.gov
// @connect gismap.cityofboise.org
// @connect gismap.co.juneau.wi.us
// @connect gismap.co.marshall.mn.us
// @connect gismap.co.norman.mn.us
// @connect gismap.co.red-lake.mn.us
// @connect gismapping.stafford.va.us
// @connect gismaps.cityofboise.org
// @connect gismaps.cityofgreer.org
// @connect gismaps.co.cerro-gordo.ia.us
// @connect gismaps.columbiapa.org
// @connect gismaps.flower-mound.com
// @connect gismaps.fultoncountyga.gov
// @connect gismaps.guelph.ca
// @connect gismaps.hctra.org
// @connect gismaps.kingcounty.gov
// @connect gismaps.redwoodcity.org
// @connect gismaps.sedgwickcounty.org
// @connect gismaps.wichita.gov
// @connect gismapserver.leegov.com
// @connect gismo.spokanecounty.org
// @connect gisonline.greenvillenc.gov
// @connect gisp.co.genesee.ny.us
// @connect gisp.mcgi.state.mi.us
// @connect gisportal.calaverascounty.gov
// @connect gisportal.champaignil.gov
// @connect gisportal.co.madison.il.us
// @connect gisportal.co.warren.oh.us
// @connect gisportal.dorchestercounty.net
// @connect gisportal.dot.ct.gov
// @connect gisportal.fnsb.gov
// @connect gisportal.ircgov.com
// @connect gisportal.ontarioca.gov
// @connect gisportal.stocktonca.gov
// @connect gisportal.stpgov.org
// @connect gisportal.whitehorse.ca
// @connect gispro.porterco.org
// @connect gisprod10.co.fresno.ca.us
// @connect gisprodops.chesco.org
// @connect gispub.cityofaspen.com
// @connect gispub.co.washington.or.us
// @connect gispublic.co.lake.ca.us
// @connect gispw.coloradosprings.gov
// @connect gisrevprxy.seattle.gov
// @connect gisserver.christiancountymo.gov
// @connect gisservice.cityofmesquite.com
// @connect gisservicemt.gov
// @connect gisservices.chathamcountync.gov
// @connect gisservices.chathamnc.org
// @connect gisservices.co.anoka.mn.us
// @connect gisservices.douglasnv.us
// @connect gisservices.its.ny.gov
// @connect gisservices.oakgov.com
// @connect gisservices.surrey.ca
// @connect gisservices2.suffolkcountyny.gov
// @connect gissites4.centrecountypa.gov
// @connect gissvr.watgov.org
// @connect gisweb-18.ci.killeen.tx.us
// @connect gisweb-adapters.bcpa.net
// @connect gisweb.albemarle.org
// @connect gisweb.birminghamal.gov
// @connect gisweb.casscountynd.gov
// @connect gisweb.champaignil.gov
// @connect gisweb.ci.manteca.ca.us
// @connect gisweb.co.aitkin.mn.us
// @connect gisweb.co.mower.mn.us
// @connect gisweb.co.wilkin.mn.us
// @connect gisweb.fdlco.wi.gov
// @connect gisweb.fortbendcountytx.gov
// @connect gisweb.jeffcowa.us
// @connect gisweb.miamidade.gov
// @connect gisweb.pwcva.gov
// @connect gisweb.wycokck.org
// @connect gisweb2014.gordoncounty.org
// @connect giswebservices.countygp.ab.ca
// @connect giswww.westchestergov.com
// @connect git.co.tioga.ny.us
// @connect gmdnags.colliercountyfl.gov
// @connect grant.co.jefferson.id.us
// @connect gweb01.co.olmsted.mn.us
// @connect harpergis.integritygis.com
// @connect haslet.halff.com
// @connect hazards.fema.gov
// @connect hdgis.ingham.org
// @connect heartlandmpo.com
// @connect helenamontanamaps.org
// @connect henrygis.integritygis.com
// @connect hgis.hialeahfl.gov
// @connect holtgis.integritygis.com
// @connect host.cdmsmithgis.com
// @connect hostingdata2.tighebond.com
// @connect hostingdata3.tighebond.com
// @connect huntsvillegis.com
// @connect ifgis.idahofallsidaho.gov
// @connect ihost.tularecounty.ca.gov
// @connect imap.klickitatcounty.org
// @connect ims.districtiii.org
// @connect intervector.leoncountyfl.gov
// @connect iowagis.integritygis.com
// @connect jeffarcgis.jeffersoncountywi.gov
// @connect joplingis.org
// @connect k3gis.com
// @connect kanplan.ksdot.gov
// @connect kcgis.kentoncounty.org
// @connect kenhagis.kenha.co.ke
// @connect kygisserver.ky.gov
// @connect lacledegis.integritygis.com
// @connect lafayettegis.integritygis.com
// @connect landrecords.greencountywi.org
// @connect lawrencegis.integritygis.com
// @connect lcapps.co.lucas.oh.us
// @connect lcmaps.lanecounty.org
// @connect lee-arcgis.leecountync.gov
// @connect lincolngis.integritygis.com
// @connect lio.milwaukeecountywi.gov
// @connect livingstongis.integritygis.com
// @connect location.cabarruscounty.us
// @connect logis.loudoun.gov
// @connect loraincountyauditor.com
// @connect lrs.co.columbia.wi.us
// @connect lucity.sbpg.net
// @connect macongis.integritygis.com
// @connect madison.rexburg.org
// @connect madisongis.cityofalbany.net
// @connect manitowocmaps.info
// @connect map.claycountymn.gov
// @connect map.co.clear-creek.co.us
// @connect map.co.clearwater.mn.us
// @connect map.co.merced.ca.us
// @connect map.co.thurston.wa.us
// @connect map.co.trempealeau.wi.us
// @connect map.coppelltx.gov
// @connect map.eaglecounty.us
// @connect map.haltonhills.ca
// @connect map.newberrycounty.net
// @connect map.opkansas.org
// @connect map.oshawa.ca
// @connect map.pikepass.com
// @connect map.rdn.bc.ca
// @connect map.stclairco.com
// @connect map.sussexcountyde.gov
// @connect map.wyoroad.info
// @connect map11.incog.org
// @connect mapd.kcmo.org
// @connect mapdata.baytown.org
// @connect mapdata.lasvegasnevada.gov
// @connect mapdata.tucsonaz.gov
// @connect mapit.fortworthtexas.gov
// @connect mapit.tarrantcounty.com
// @connect mapitwest.fortworthtexas.gov
// @connect mapping.adamscountypa.gov
// @connect mapping.burlington.ca
// @connect mapping.chilliwack.com
// @connect mapping.kenoshacountywi.gov
// @connect mapping.mitchellcounty.org
// @connect mapping.modot.org
// @connect mappmycity.ca
// @connect maps.adaok.com
// @connect maps.alexandercountync.gov
// @connect maps.alexandriava.gov
// @connect maps.austintexas.gov
// @connect maps.banff.ca
// @connect maps.bannockcounty.us
// @connect maps.bayfieldcounty.wi.gov
// @connect maps.bcad.org
// @connect maps.belmont.gov
// @connect maps.berkeleywv.org
// @connect maps.boonecountyil.org
// @connect maps.bossierparishgis.org
// @connect maps.bouldercounty.org
// @connect maps.brazoriacountytx.gov
// @connect maps.brla.gov
// @connect maps.brookhavenga.gov
// @connect maps.bryantx.gov
// @connect maps.burlesontx.com
// @connect maps.butlercountyauditor.org
// @connect maps.cambridge.ca
// @connect maps.canyonco.org
// @connect maps.capturecama.com
// @connect maps.casperwy.gov
// @connect maps.chautauquacounty.com
// @connect maps.cherokeecounty-nc.gov
// @connect maps.ci.longmont.co.us
// @connect maps.ci.nacogdoches.tx.us
// @connect maps.cityhs.net
// @connect maps.cityofconroe.org
// @connect maps.cityofhenderson.com
// @connect maps.cityofls.net
// @connect maps.cityofmadison.com
// @connect maps.cityofmobile.org
// @connect maps.cityofsherman.com
// @connect maps.cityoftulsa.org
// @connect maps.cityofwaterlooiowa.com
// @connect maps.clarkcountynv.gov
// @connect maps.claycountygov.com
// @connect maps.clermontauditor.org
// @connect maps.clintoncountypa.com
// @connect maps.co.blaine.id.us
// @connect maps.co.ellis.tx.us
// @connect maps.co.forsyth.nc.us
// @connect maps.co.goodhue.mn.us
// @connect maps.co.gov
// @connect maps.co.grayson.tx.us
// @connect maps.co.itasca.mn.us
// @connect maps.co.kendall.il.us
// @connect maps.co.kern.ca.us
// @connect maps.co.lincoln.wi.us
// @connect maps.co.palm-beach.fl.us
// @connect maps.co.polk.or.us
// @connect maps.co.pueblo.co.us
// @connect maps.co.ramsey.mn.us
// @connect maps.co.shawano.wi.us
// @connect maps.co.warren.oh.us
// @connect maps.co.washington.mn.us
// @connect maps.coj.net
// @connect maps.collincountytx.gov
// @connect maps.countyofmerced.com
// @connect maps.crc.ga.gov
// @connect maps.ctmetro.org
// @connect maps.currituckcountync.gov
// @connect maps.cvrd.ca
// @connect maps.dancgis.org
// @connect maps.dcad.org
// @connect maps.delco-gis.org
// @connect maps.deltacountyco.gov
// @connect maps.deschutes.org
// @connect maps.desotocountyms.gov
// @connect maps.dmgov.org
// @connect maps.dot.nh.gov
// @connect maps.dotd.la.gov
// @connect maps.douglascountyga.gov
// @connect maps.douglascountywa.net
// @connect maps.dsm.city
// @connect maps.durham.ca
// @connect maps.elbertcounty-co.gov
// @connect maps.escpa.org
// @connect maps.etcog.org
// @connect maps.evansvillegis.com
// @connect maps.fayetteville-ar.gov
// @connect maps.fishers.in.us
// @connect maps.flathead.mt.gov
// @connect maps.floridadisaster.org
// @connect maps.frederickcountymd.gov
// @connect maps.fredericksburgva.gov
// @connect maps.garfield-county.com
// @connect maps.garlandtx.gov
// @connect maps.gov.bc.ca
// @connect maps.grcity.us
// @connect maps.groton-ct.gov
// @connect maps.grundyco.org
// @connect maps.haldimandcounty.on.ca
// @connect maps.hayward-ca.gov
// @connect maps.haywoodnc.net
// @connect maps.highlandvillage.org
// @connect maps.hokecounty.org
// @connect maps.huerfano.us
// @connect maps.huntsvilleal.gov
// @connect maps.iredellcountync.gov
// @connect maps.itos.uga.edu
// @connect maps.jocogov.org
// @connect maps.kamloops.ca
// @connect maps.kytc.ky.gov
// @connect maps.lacity.org
// @connect maps.lagrange-ga.org
// @connect maps.lakecountyil.gov
// @connect maps.laramiecounty.com
// @connect maps.lcwy.org
// @connect maps.lebanontn.org
// @connect maps.lex-co.com
// @connect maps.lexingtonky.gov
// @connect maps.libertymo.gov
// @connect maps.lincolncountysd.org
// @connect maps.linkgis.org
// @connect maps.london.ca
// @connect maps.matsugov.us
// @connect maps.mckinneytexas.org
// @connect maps.meshekgis.com
// @connect maps.miamigov.com
// @connect maps.midlandtexas.gov
// @connect maps.monroecounty.gov
// @connect maps.muskegoncountygis.com
// @connect maps.nashville.gov
// @connect maps.ncpafl.com
// @connect maps.nevadacountyca.gov
// @connect maps.nj.gov
// @connect maps.normanok.gov
// @connect maps.northaugustasc.gov
// @connect maps.ocgov.net
// @connect maps.opkansas.org
// @connect maps.orcity.org
// @connect maps.ottawa.ca
// @connect maps.palmcoastgov.com
// @connect maps.parkco.us
// @connect maps.phoenix.gov
// @connect maps.pitkincounty.com
// @connect maps.planogis.org
// @connect maps.pottercountypa.net
// @connect maps.prcity.com
// @connect maps.raleighnc.gov
// @connect maps.richlandcountyoh.us
// @connect maps.rutherfordcountytn.gov
// @connect maps.santa-clarita.com
// @connect maps.santabarbaraca.gov
// @connect maps.sbcounty.gov
// @connect maps.sccmo.org
// @connect maps.semogis.com
// @connect maps.sfdpw.org
// @connect maps.sgcityutah.gov
// @connect maps.shelbyal.com
// @connect maps.slocity.org
// @connect maps.spartanburgcounty.org
// @connect maps.springfieldmo.gov
// @connect maps.steamboatsprings.net
// @connect maps.stlouisco.com
// @connect maps.swaincountync.gov
// @connect maps.tippecanoe.in.gov
// @connect maps.townofcary.org
// @connect maps.udot.utah.gov
// @connect maps.vancouver.ca
// @connect maps.vcgi.vermont.gov
// @connect maps.ventura.org
// @connect maps.victoria.ca
// @connect maps.victoriatx.org
// @connect maps.vilascountywi.gov
// @connect maps.vtrans.vermont.gov
// @connect maps.wake.gov
// @connect maps.washco-md.net
// @connect maps.washcowisco.gov
// @connect maps.whiterockcity.ca
// @connect maps1.brampton.ca
// @connect maps1.eriecounty.oh.gov
// @connect maps1.larimer.org
// @connect maps11.eriecounty.oh.gov
// @connect maps2.bgadd.org
// @connect maps2.cattco.org
// @connect maps2.ci.euless.tx.us
// @connect maps2.columbus.gov
// @connect maps2.dcgis.dc.gov
// @connect maps2.san-marcos.net
// @connect maps2.timmons.com
// @connect maps2.vcgov.org
// @connect maps6.stlouis-mo.gov
// @connect maps7.eriecounty.oh.gov
// @connect maps8.eriecounty.oh.gov
// @connect mapsdev.hamiltontn.gov
// @connect mapserv.cityofloveland.org
// @connect mapserv.mesquitenv.gov
// @connect mapservice.nmstatelands.org
// @connect mapservices.crd.bc.ca
// @connect mapservices.gis.saccounty.net
// @connect mapservices.gov.yk.ca
// @connect mapservices.nps.gov
// @connect mapservices.pasda.psu.edu
// @connect mapservices.santacruzcountyaz.gov
// @connect mapservices.sccgov.org
// @connect mapservices.weather.noaa.gov
// @connect mapservices1.jeffco.us
// @connect mapservices2.jeffco.us
// @connect mariesgis.integritygis.com
// @connect mariongis.integritygis.com
// @connect mcdonaldgis.integritygis.com
// @connect mcgis.mesacounty.us
// @connect mcgis.mohave.gov
// @connect mcgis4.monroecounty-fl.gov
// @connect mcmap.montrosecounty.net
// @connect mcogis.co.marion.oh.us
// @connect millergis.integritygis.com
// @connect mms.hursttx.gov
// @connect mndotgis.dot.state.mn.us
// @connect moberlygis.integritygis.com
// @connect mobile.alamedaca.gov
// @connect moniteaugis.integritygis.com
// @connect morgangis.integritygis.com
// @connect msdisweb.missouri.edu
// @connect mycity2.houstontx.gov
// @connect navigator.state.or.us
// @connect newtongis.integritygis.com
// @connect nhgeodata.unh.edu
// @connect nobgis.cityofnoblesville.org
// @connect northlake.halff.com
// @connect nsgiwa.novascotia.ca
// @connect nspdcwebsrv.csuchico.edu
// @connect oak.co.lake-of-the-woods.mn.us
// @connect oc17maps.co.oconto.wi.us
// @connect ocgis4.ocfl.net
// @connect oncorng.co.ontario.ny.us
// @connect opengis.regina.ca
// @connect operationserver.ci.henderson.nc.us
// @connect orfmaps.norfolk.gov
// @connect osagegis.integritygis.com
// @connect pagis.org
// @connect pamap.putnam-fl.gov
// @connect parcelmap.ashtabulacounty.us
// @connect parcels.rsdigital.com
// @connect parcelviewer.geodecisions.com
// @connect pascogis.pascocountyfl.net
// @connect pgis.plantation.org
// @connect phelpsgis.integritygis.com
// @connect polaris2.mecklenburgcountync.gov
// @connect polkgis.integritygis.com
// @connect portal.carolinabeach.org
// @connect portal.carson.org
// @connect portal.henrico.gov
// @connect portal.niagarafalls.ca
// @connect programs.iowadnr.gov
// @connect propaccess.wadtx.com
// @connect propertyviewer.andersoncountysc.org
// @connect proxy2.roktech.net
// @connect psportal.harrisoncountywv.com
// @connect pubgis.ci.lubbock.tx.us
// @connect public.co.wasco.or.us
// @connect public1.co.waupaca.wi.us
// @connect publicmap01.co.st-clair.il.us
// @connect publicmaps.txkusa.org
// @connect pulaskigis.integritygis.com
// @connect putnamcountygis.com
// @connect pwmaps.cityofloveland.org
// @connect pwmaps.reno.gov
// @connect rallsgis.integritygis.com
// @connect raygis.integritygis.com
// @connect rc-arcgis01.co.rice.mn.us
// @connect rdsgis.nctgis.nct911.org
// @connect renogis3.renogov.org
// @connect roads.udot.utah.gov
// @connect rockgis.co.rock.wi.us
// @connect rockgis.rockfordil.gov
// @connect romefloyd.agdmaps.com
// @connect rptsgisweb.oswegocounty.com
// @connect salinegis.integritygis.com
// @connect saludacountysc.net
// @connect sccgis.santacruzcountyca.gov
// @connect scgis.summitoh.net
// @connect scgisa.starkcountyohio.gov
// @connect sdgis.sd.gov
// @connect secure.boonecountygis.com
// @connect sedaliagis.integritygis.com
// @connect see-eldorado.edcgov.us
// @connect server.boundarycountyid.org
// @connect server1.mapxpress.net
// @connect server2.mapxpress.net
// @connect services.aadnc-aandc.gc.ca
// @connect services.arcgis.com
// @connect services.gis.ca.gov
// @connect services.gisqatar.org.qa
// @connect services.mh-gis.com
// @connect services.nconemap.gov
// @connect services.sagis.org
// @connect services.wvgis.wvu.edu
// @connect services1.arcgis.com
// @connect services2.arcgis.com
// @connect services2.integritygis.com
// @connect services3.arcgis.com
// @connect services5.arcgis.com
// @connect services6.arcgis.com
// @connect services7.arcgis.com
// @connect services8.arcgis.com
// @connect services9.arcgis.com
// @connect showlowmaps.com
// @connect skyview.hornershifrin.com
// @connect slcgis.stlucieco.gov
// @connect smgis.sanmarcostx.gov
// @connect smithvillegis.integritygis.com
// @connect smpesri.scdot.org
// @connect socogis.sonomacounty.ca.gov
// @connect spatial.gishost.com
// @connect spatial.jacksoncountyor.gov
// @connect spatialags.vhb.com
// @connect stclairgis.integritygis.com
// @connect stmgis.stmarysmd.com
// @connect stokescountygis.com
// @connect stonegis.integritygis.com
// @connect svr4.sumtercountysc.org
// @connect tcgisws.tooeleco.gov
// @connect tcweb.co.teller.co.us
// @connect tfportal.tfid.org
// @connect tharcgis2.thewoodlands-tx.gov
// @connect tigerweb.geo.census.gov
// @connect tiogagis.tiogacountypa.us
// @connect tnmap.tn.gov
// @connect tpwd.texas.gov
// @connect tsc-gis-ags101a.schneidercorp.com
// @connect twu.newedgeservices.com
// @connect utility.arcgis.com
// @connect vernongis.integritygis.com
// @connect vginmaps.vdem.virginia.gov
// @connect vtransmap01.aot.state.vt.us
// @connect wallawallagis.com
// @connect warrengis.integritygis.com
// @connect wcg-gisweb.co.worcester.md.us
// @connect wcgis3.co.winnebago.wi.us
// @connect wcgisweb.washoecounty.us
// @connect wcoh.geopowered.com
// @connect web.binghamid.gov
// @connect web2.co.ottertail.mn.us
// @connect web2.kcsgis.com
// @connect web3.kcsgis.com
// @connect web4.kcsgis.com
// @connect web5.kcsgis.com
// @connect webadaptor.glynncounty-ga.gov
// @connect webgis.bedfordcountyva.gov
// @connect webgis.co.davidson.nc.us
// @connect webgis.durhamnc.gov
// @connect webgis.lafayetteassessor.com
// @connect webgis.providenceri.gov
// @connect webgis.waterburyct.org
// @connect webgis.yorbalindaca.gov
// @connect webmap.co.jackson.ms.us
// @connect webmap.jeffparish.net
// @connect webmap.trueautomation.com
// @connect webmaps.elkgrovecity.org
// @connect webmaps.sjcounty.net
// @connect webportal.co.marquette.wi.us
// @connect websrv31.clallamcountywa.gov
// @connect webstergis.integritygis.com
// @connect wfs.ksdot.org
// @connect wfs.schneidercorp.com
// @connect ws.lioservices.lrc.gov.on.ca
// @connect wvsams.mapwv.org
// @connect ww1.bucoks.com
// @connect ww8.yorkmaps.ca
// @connect www.1stdistrict.org
// @connect www.adacountyassessor.org
// @connect www.adamscountyarcserver.com
// @connect www.ancgis.com
// @connect www.apps.geomatics.gov.nt.ca
// @connect www.bartowgis.org
// @connect www.bcgis.com
// @connect www.bcpao.us
// @connect www.centralilmaps.com
// @connect www.cmbgis.com
// @connect www.colesco.illinois.gov
// @connect www.ctgismaps2.ct.gov
// @connect www.denvergov.org
// @connect www.dmcwebgis.com
// @connect www.efsedge.com
// @connect www.finneycountygis.com
// @connect www.franklinmo.net
// @connect www.gcgis.org
// @connect www.gfgis.com
// @connect www.gis.hctx.net
// @connect www.gis.sjcfl.us
// @connect www.gismidwest.com
// @connect www.gisonline.ms.gov
// @connect www.greenwoodsc.gov
// @connect www.hernandocountygis-florida.us
// @connect www.hogarcmaps.org
// @connect www.horrycountysc.gov
// @connect www.landmarkgeospatial.com
// @connect www.laurenscountygis.org
// @connect www.mcgisweb.org
// @connect www.mchenrycountygis.org
// @connect www.midmogis.org
// @connect www.monroegis.org
// @connect www.mymanatee.org
// @connect www.ocgis.com
// @connect www.portlandmaps.com
// @connect www.rdcogis.com
// @connect www.sciotocountyengineer.org
// @connect www.semogis.com
// @connect www.sgrcmaps.com
// @connect www.sjmap.org
// @connect www.smithcountymapsite.org
// @connect www.tgisites.com
// @connect www.valorgis.com
// @connect www.waynecounty.com
// @connect www.webgis.net
// @connect www.yamhillcountygis.com
// @connect www1.cityofwebster.com
// @connect www2.ci.lancaster.oh.us
// @connect www2.pottcounty.org
// @connect www3.multco.us
// @connect www7.co.union.oh.us
// @connect xara1-4.cityofpetaluma.net
// @connect xmaps.indy.gov
// ==/UserScript==
/* global WazeWrap, _, turf, ESTreeProcessor, bootstrap, OpenLayers, wmeGisLBBOX */
(async function main() {
'use strict';
// **************************************************************************************************************
// IMPORTANT: Update this when releasing a new version of script
// **************************************************************************************************************
const SHOW_UPDATE_MESSAGE = true;
const SCRIPT_VERSION_CHANGES = [
'Minor update: 2025.08.10.00',
'Layer definitions now load via Google Sheets Visualization API (/gviz endpoint).',
'No more API key or referrer restrictions so loading is more reliable in all browsers.',
'Fixes issues caused by privacy extensions/content blockers (like AdGuard).',
];
const GF_URL = 'https://greasyfork.org/scripts/369632-wme-gis-layers';
// Used in tooltips to tell people who to report issues to. Update if a new author takes ownership of this script.
const SCRIPT_AUTHOR = 'MapOMatic / JS55CT';
const REQUEST_FORM_URL = 'https://docs.google.com/forms/d/e/1FAIpQLSevPQLz2ohu_LTge9gJ9Nv6PURmCmaSSjq0ayOJpGdRr2xI0g/viewform?usp=pp_url&entry.2116052852={username}';
const DEFAULT_LAYER_NAME = 'GIS Layers - Default';
const ROAD_LAYER_NAME = 'GIS Layers - Roads';
/**
* @typedef {Object} StyleDefinition
* @property {string} [fillColor]
* @property {number} [pointRadius]
* @property {string} [label]
* @property {number} [fillOpacity]
* @property {string} [strokeColor]
* @property {number} [strokeOpacity]
* @property {number} [strokeWidth]
* @property {string} [fontColor]
* @property {number|string} [fontSize]
* @property {string} [labelOutlineColor]
* @property {number|string} [labelOutlineWidth]
* @property {string} [fontWeight]
* @property {number} [labelYOffset]
* @property {string} [labelAlign]
* @property {string} [pathLabel]
* @property {boolean} [labelSelect]
* @property {string|number} [pathLabelYOffset]
* @property {string|number} [pathLabelCurve]
* @property {string|number} [pathLabelReadable]
* @property {boolean} [stroke]
*/
/** @type {StyleDefinition} */
const DEFAULT_STYLE = {
fillColor: '#000',
pointRadius: 4,
label: '${getLabel}',
fillOpacity: 0.95,
strokeColor: '#ffa500',
strokeOpacity: 0.95,
strokeWidth: 1.5,
fontColor: '#ffc520',
fontSize: '13',
labelOutlineColor: 'black',
labelOutlineWidth: 3,
};
/** @type {Object.<string, StyleDefinition>} */
const LAYER_STYLES = {
cities: {
fillOpacity: 0.3,
fillColor: '#f65',
strokeColor: '#f65',
fontColor: '#f62',
},
forests_parks: {
fillOpacity: 0.4,
fillColor: '#585',
strokeColor: '#484',
fontColor: '#8b8',
},
milemarkers: {
strokeColor: '#fff',
fontColor: '#fff',
fontWeight: 'bold',
fillOpacity: 0,
labelYOffset: 10,
pointRadius: 2,
fontSize: 12,
},
parcels: {
fillOpacity: 0,
fillColor: '#ffa500',
},
points: {
strokeColor: '#000',
fontColor: '#0ff',
fillColor: '#0ff',
labelYOffset: -10,
labelAlign: 'ct',
},
post_offices: {
strokeColor: '#000',
fontColor: '#f84',
fillColor: '#f84',
fontWeight: 'bold',
labelYOffset: -10,
labelAlign: 'ct',
},
state_parcels: {
fillOpacity: 0,
strokeColor: '#e62',
fillColor: '#e62',
fontColor: '#e73',
},
state_points: {
strokeColor: '#000',
fontColor: '#3cf',
fillColor: '#3cf',
labelYOffset: -10,
labelAlign: 'ct',
},
road_labels: {
strokeOpacity: 0,
fillOpacity: 0,
fontColor: '#faf',
},
structures: {
fillOpacity: 0,
strokeColor: '#f7f',
fontColor: '#f7f',
},
};
/** @type {StyleDefinition} */
let ROAD_STYLE = {
pointRadius: 12,
fillColor: '#369',
pathLabel: '${getLabel}',
label: '',
fontColor: '#faf',
labelSelect: true,
pathLabelYOffset: '${getOffset}',
pathLabelCurve: '${getSmooth}',
pathLabelReadable: '${getReadable}',
labelAlign: '${getAlign}',
labelOutlineWidth: 3,
labelOutlineColor: '#000',
strokeWidth: 3,
stroke: true,
strokeColor: '#f0f',
strokeOpacity: 0.4,
fontWeight: 'bold',
fontSize: 11,
};
/**
* Common regexes used for label cleansing/transformation.
* @type {Object.<string, RegExp>}
*/
const _regexReplace = {
// Strip leading zeros or blank full label for any label starting with a non-digit or
// is a Zero Address, use with '' as replace.
r0: /^(0+(\s.*)?|\D.*)/,
// Strip Everything After Street Type to end of the string by use $1 and $2 capture
// groups, use with replace '$1$2'
// eslint-disable-next-line max-len
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,
// Strip SPACE 5 Digits from end of string, use with replace ''
r2: /\s\d{5}$/,
// Strip Everything after a "~", ",", ";" to the end of the string, use with replace ''
r3: /(~|,|;|\s?\r\n).*$/,
// Move the digits after the last space to before the rest of the string using, use with
// replace '$2 $1'
r4: /^(.*)\s(\d+).*/,
// Insert newline between digits (including "-") and everything after the digits,
// except(and before) a ",", use with replace '$1\n$2'
r5: /^([-\d]+)\s+([^,]+).*/,
// Insert newline between digits and everything after the digits, use with
// replace '$1\n$2'
r6: /^(\d+)\s+(.*)/,
};
/**
* @typedef {Object} GisLayer
* @property {string} id - Unique identifier for the GIS layer.
* @property {number} enabled - 1 if the layer is enabled, 0 otherwise.
* @property {string} name - Human-readable name of the layer.
* @property {string} country - Country ISO code associated with the layer (uppercased).
* @property {string} subL1 - Subdivision level 1 code (uppercased).
* @property {string[]} [subL2] - Optional array of subdivision level 2 names (parsed from comma-separated string).
* @property {string} url - Service URL for the GIS layer.
* @property {string} [where] - Optional SQL/query filter string.
* @property {string[]} [labelFields] - Array of label field names (parsed, or [''] if missing).
* @property {string} [processLabel] - Optional label processing JavaScript code (as a string).
* @property {boolean} [labelProcessingError] - True if an error occurred while compiling processLabel.
* @property {Object|string} [style] - Style object (parsed from JSON) or "roads" for road layers.
* @property {boolean} [isRoadLayer] - True if the style is set to "roads".
* @property {number|null} [visibleAtZoom] - Minimum zoom level at which the layer is visible (or null).
* @property {number|null} [labelsVisibleAtZoom] - Minimum zoom level at which labels are visible (or null).
* @property {string} [restrictTo] - Restriction rules for this layer (parsed for "notAllowed").
* @property {boolean} [notAllowed] - True if restrictions disallow the current user (based on restrictTo).
* @property {string} [oneTimeAlert] - One-time alert message for this layer.
* @property {string} [platform] - Detected service platform (e.g., "ArcGIS", "SocrataV2", "SocrataV3", "Other").
* @property {string} countrySubL1 - Computed country and SubL1 code combined (e.g., "USA-CALIFORNIA").
*/
/** @type {GisLayer[]} */
let _gisLayers = [];
/**
* Information about a single country in results.
* @typedef {object} WhatsInViewCountry
* @property {string} ISO_ALPHA2
* @property {string} ISO_ALPHA3
* @property {number} Sub_level
* @property {string} [source]
* @property {Object<string, Object>|Object} subL1
* Intersecting subdivisions (states/counties/etc). Structure depends on country and precision.
*/
/**
* Main return type for whatsInView.
* - Keys are country names, values are country info objects.
* @typedef {Object.<string, WhatsInViewCountry>} WhatsInViewResult
*/
/** @type {WhatsInViewResult} */
let _whatsInView = {};
/** @type {Set<string>} Set of ISO_ALPHA3 country codes already loaded */
const alreadyLoadedCountries = new Set();
/** @type {Set<string>} Set of subdivision (subL1_id) codes already loaded */
const alreadyLoadedSubL1 = new Set();
/**
* @typedef {object} ViewportBBox
* @property {number} minLon
* @property {number} minLat
* @property {number} maxLon
* @property {number} maxLat
*/
/**
* @typedef {object} wmeGisLBBOX
* @property {(url: string) => Promise<object>} fetchJsonWithCache
* @property {(viewportBbox: ViewportBBox) => Promise<Array<{ISO_ALPHA2:string, ISO_ALPHA3:string, name:string, Sub_level:number, source:string}>>} getIntersectingCountries
* @property {() => Promise<Object>} getCountriesAndSubsJson
* @property {(intersectingCountries: Object) => void} cleanIntersectingData
* @property {(countyCode: string, subCode: string, subSubCode: string, viewportBbox: ViewportBBox, returnGeoJson?: boolean) => Promise<boolean|Object>} fetchAndCheckGeoJsonIntersection
* @property {(viewportBbox: ViewportBBox, highPrecision?: boolean, returnGeoJson?: boolean) => Promise<Object>} getIntersectingStatesAndCounties
* @property {(countryObj: Object, viewportBbox: ViewportBBox) => Promise<Object>} getIntersectingSubdivisions
* @property {(viewportBbox: ViewportBBox, highPrecision?: boolean, returnGeoJson?: boolean) => Promise<Object>} whatsInView
*/
/** @type {wmeGisLBBOX} */
const WmeGisLBBOX = new wmeGisLBBOX(); // Create and reuse this instance as wmeGisLBBOX uses an instance-level cache (i.e., this.cache)
/**
* Maps a string key (`countryId-countryId` or `countryId-subdivisionId`) to a full name string.
* Example keys: "US-US", "US-CA", etc.
* Example values: "US - United States", "US - California", etc.
* @type {Object.<string, string>}
*/
let countrySubdivisionMapping = {};
/**
* Asynchronously builds a mapping from 'countryId-subdivisionId' identifiers to their respective names.
*
* Retrieves country and subdivision data using WmeGisLBBOX.getCountriesAndSubsJson(),
* iterates over the data, and constructs an object where each key is a combination of
* country and subdivision IDs and each value is the corresponding name ("US - California").
*
* @returns {Promise<Object.<string, string>>} Resolves to the mapping object.
*/
async function buildCountrySubdivisionMapping() {
const countriesAndSubs = await WmeGisLBBOX.getCountriesAndSubsJson();
for (const [countryId, countryData] of Object.entries(countriesAndSubs)) {
const countryName = countryData.name;
// Add country itself with key 'countryId-countryId'
countrySubdivisionMapping[`${countryId}`] = countryName;
countrySubdivisionMapping[`${countryId}-${countryId}`] = `${countryId} - ${countryName}`;
if (countryData.subL1) {
for (const [subId, subData] of Object.entries(countryData.subL1)) {
const subName = subData.name;
const key = `${countryId}-${subId}`;
const value = `${countryId} - ${subName}`;
countrySubdivisionMapping[key] = value;
}
}
}
return countrySubdivisionMapping;
}
/**
* Helper for mapping between country-subdivision keys and their full names.
*/
const NameMapper = {
/**
* Converts a full name ("US - California") to its key ("US-CA").
* @param {string} fullName - Full name to convert.
* @returns {string|undefined} Matching key, or undefined if not found.
*/
toKey(fullName) {
return Object.entries(countrySubdivisionMapping).find(([, value]) => value === fullName)?.[0];
},
/**
* Converts a key ("US-CA") to its full name ("US - California").
* @param {string} key
* @returns {string} The corresponding full name or undefined.
*/
toFullName(key) {
return countrySubdivisionMapping[key];
},
/**
* Returns all full names in the mapping.
* @returns {Array<string>} Array of all full names.
*/
toFullNameArray() {
return Object.values(countrySubdivisionMapping);
},
/**
* Returns all keys in the mapping.
* @returns {Array<string>} Array of all keys.
*/
toKeyArray() {
return Object.keys(countrySubdivisionMapping);
},
};
/** @type {number} */
const DEFAULT_VISIBLE_AT_ZOOM = 18;
/** @type {string} */
const SETTINGS_STORE_NAME = 'wme_gis_layers_fl';
/** @type {string} */
const scriptName = GM_info.script.name;
/** @type {string} */
const scriptVersion = GM_info.script.version;
/** @type {string} */
const downloadUrl = 'https://greasyfork.org/scripts/369632-wme-gis-layers/code/WME%20GIS%20Layers.user.js';
/**
* @typedef {Object} ScriptUpdateMonitorArgs
* @property {string} [scriptVersion]
* @property {string} downloadUrl
* @property {string} [metaUrl]
* @property {RegExp} [metaRegExp]
*/
/**
* @typedef {Object} BootstrapArgs
* @property {string} [scriptName]
* @property {string} [scriptId]
* @property {boolean} [useWazeWrap=false]
* @property {ScriptUpdateMonitorArgs} [scriptUpdateMonitor]
* @property {(wmeSdk: Object) => void} [callback]
*/
/**
* Initializes WME SDK and starts ScriptUpdateMonitor using bootstrap().
* @type {Object}
*/
const sdk = await bootstrap(
/** @type {BootstrapArgs} */ ({
scriptUpdateMonitor: { downloadUrl },
})
);
/**
* @typedef {Object} Offset
* @property {number} x - X pixel offset
* @property {number} y - Y pixel offset
*/
/**
* @typedef {Object} LayerSettings
* @property {Offset=} offset - Optional XY offset for a layer.
*/
/**
* @typedef {Object} LayerGroupSettings
* @property {Array<string>} selectedSubL1 - Array of selected sub-L1 region codes.
* @property {Array<string>} visibleLayers - Array of visible layer IDs in this group.
* @property {Object.<string, boolean>} collapsedSections - Map of section names to collapsed state (can be empty).
* @property {string} addrLabelDisplay - Address label display mode ("all" in this sample).
* @property {boolean} fillParcels - Whether to fill parcels in this group.
*/
/**
* @typedef {Object} Settings
* @property {string} lastVersion - The last version number this script saw, e.g., "2025.08.01.000".
* @property {Array<string>} visibleLayers - Array of visible layer IDs.
* @property {boolean} onlyShowApplicableLayers - Whether to show only applicable layers.
* @property {boolean} onlyShowApplicableLayersZoom - Restrict showing applicable layers to a certain zoom.
* @property {Array<string>} selectedSubL1 - Selected sub-L1 region codes (e.g., ["CAN-CAN", "USA-CT"]).
* @property {boolean} enabled - Whether this script is enabled.
* @property {boolean} fillParcels - Whether to fill parcel polygons.
* @property {Object.<string, number>} oneTimeAlerts - Map of alert keys to offset numbers (possibly UNIX timestamps or magic numbers).
* @property {Object.<string, LayerSettings>} layers - Map of layer IDs to layer settings.
* @property {Object.<string, string>} shortcuts - Map of shortcut IDs to key combo strings, e.g. "2,67".
* @property {boolean} isPopupVisible - Is the config/settings popup currently visible.
* @property {boolean} useAcronyms - Whether to use acronyms for certain values.
* @property {boolean} useTitleCase - Whether to use title case in labels.
* @property {boolean} useStateHwy - Whether to use "State Hwy" format for roads.
* @property {boolean} removeNewLines - Whether to remove new lines from names/labels.
* @property {Object.<string, boolean>} collapsedSections - Map of section names (region codes, etc) to collapsed state.
* @property {Object.<string, LayerGroupSettings>} layerGroups - Map of group names to per-group settings.
* @property {string} addrLabelDisplay - Display mode for address labels ("all" in this sample).
* @property {string} socrataAppToken - Token for Socrata API access.
* @property {string} [toggleHnsOnlyShortcut] - legacy, only present Pre SDK migration, moved to shortcuts.toggleHnsOnlyShortcut
* @property {string} [toggleEnabledShortcut] - legacy, only present Pre SDK migration, moved to shortcuts.toggleEnabledShortcut
*
* @property {(layerID: string, settingName: string) => *} getLayerSetting - Get a setting for a layer.
* @property {(layerID: string, settingName: string, value: *) => void} setLayerSetting - Set a setting for a layer.
* @property {(layerID: string, settingName?: string) => void} removeLayerSetting - Remove a setting or a whole layer.
*/
/**
* User and UI settings for script, with utility methods.
* @type {Settings }
*/
let settings = /** @type {any} */ ({});
/** @type {boolean} */
let ignoreFetch = false;
/**
* @typedef {Object} LastToken
* @property {boolean} cancel - Set to true to request the operation to cancel.
* @property {Array} features - Array of features being processed.
* @property {number} layersProcessed - Number of layers processed.
*/
/**
* Tracks the current in-progress async request and provides control/status.
* @type {LastToken}
*/
let lastToken = { cancel: false, features: [], layersProcessed: 0 };
/**
* @typedef {Object} UserSession
* @property {boolean} isAreaManager
* @property {boolean} isCountryManager
* @property {number} rank
* @property {string} userName
*/
/** @type {UserSession|null} */
let userInfo = null;
// Variables to store Label popup position and selected layer
/** @type {Object.<string, Set<string>>} */
const layerLabels = {};
/** @type {boolean} */
let isPopupVisible = false;
/** @type {{left: string, top: string}} */
const popupPosition = { left: '50%', top: '50%' };
/** @type {string | null} */
let popupActiveLayer = null;
/** @type {boolean} */
let useAcronyms = false;
/** @type {boolean} */
let useTitleCase = false;
/** @type {boolean} */
let useStateHwy = false;
/** @type {boolean} */
let removeNewLines = false;
/** @type {boolean} */
const DEBUG = true;
/**
* Error logging utility.
* @param {string} message
* @param {...any} args
*/
function logError(message, ...args) {
console.error(`${scriptName}:`, message, ...args);
}
/**
* Logs a debug message if DEBUG is enabled.
* @param {string} message
* @param {...any} args
*/
function logDebug(message, ...args) {
if (DEBUG) console.debug(`${scriptName}:`, message, ...args);
}
let _layerSettingsDialog;
/**
* Dialog for configuring GIS layer settings in the UI.
* Provides shift controls, visibility at zoom, and offset reset.
*/
class LayerSettingsDialog {
#gisLayer;
#minVisibleAtZoom = 12;
#maxVisibleAtZoom = 22;
#titleText;
#visibleAtZoomInput;
constructor() {
this.#titleText = $('<span>');
const closeButton = $('<span>', {
style: 'cursor:pointer;padding-left:14px;font-size:20px;color:#eaf6ff;float:right;',
class: 'fa fa-window-close',
title: 'Close',
}).on('click', () => this.#onCloseButtonClick());
const shiftUpButton = LayerSettingsDialog.#createShiftButton('fa-angle-up').on('click', () => this.#onShiftButtonClick(0, 1));
const shiftLeftButton = LayerSettingsDialog.#createShiftButton('fa-angle-left').on('click', () => this.#onShiftButtonClick(-1, 0));
const shiftRightButton = LayerSettingsDialog.#createShiftButton('fa-angle-right').on('click', () => this.#onShiftButtonClick(1, 0));
const shiftDownButton = LayerSettingsDialog.#createShiftButton('fa-angle-down').on('click', () => this.#onShiftButtonClick(0, -1));
const resetOffsetButton = $('<button>', {
class: 'form-control',
style:
'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;',
})
.text('Reset')
.on('click', () => this.#onResetOffsetButtonClick());
this._dialogDiv = $('<div>', {
style:
// Modern blue theme & rounded & drop shadow
'position: fixed; top: 15%; left: 400px; width: 235px; z-index: 100; background: #73a9bd;' +
'border-width: 1px; border-style: solid; border-radius: 14px; box-shadow: 5px 6px 14px rgba(0,0,0,0.58);' +
'border-color: #50667b; padding: 0; font-family: inherit;',
}).append(
$('<div>').append(
// HEADER
$('<div>', {
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;',
}).append(this.#titleText, closeButton),
// BODY
$('<div>', { style: 'padding: 5px 5px 5px 5px;' }).append(
$('<div>', {
style: 'border-radius: 7px; width: 100%; padding:8px 6px 10px 8px; background:#d6e6f3; margin-bottom:6px; margin-right:0; box-sizing:border-box;',
}).append(
resetOffsetButton,
$('<input>', {
type: 'radio',
id: 'gisLayerShiftAmt1',
name: 'gisLayerShiftAmt',
value: '1',
checked: 'checked',
style: 'margin-left:4px;accent-color:#4d6a88;',
}),
$('<label>', { for: 'gisLayerShiftAmt1', style: 'margin-right:8px;margin-left:2px;color:#4d6a88;font-weight:600;font-size:13px;' }).text('1m'),
$('<input>', {
type: 'radio',
id: 'gisLayerShiftAmt10',
name: 'gisLayerShiftAmt',
value: '10',
style: 'margin-left: 6px;accent-color:#4d6a88;',
}),
$('<label>', { for: 'gisLayerShiftAmt10', style: 'color:#4d6a88;font-weight:600;font-size:13px;' }).text('10m'),
$('<div>', { style: 'padding: 6px 0 0 0;' }).append(
$('<table>', { style: 'table-layout:fixed; width:70px; height:84px; margin:auto;' }).append(
$('<tr>').append($('<td>', { align: 'center', style: 'width:20px;height:28px;' }), $('<td>', { align: 'center', style: 'width:20px;' }).append(shiftUpButton), $('<td>')),
$('<tr>').append($('<td>', { align: 'center' }).append(shiftLeftButton), $('<td>', { align: 'center' }), $('<td>', { align: 'center' }).append(shiftRightButton)),
$('<tr>').append($('<td>', { align: 'center' }), $('<td>', { align: 'center' }).append(shiftDownButton), $('<td>', { align: 'center' }))
)
)
),
$('<div>', {
style: 'border-radius: 7px; width:100%; padding:12px 8px 8px 10px; margin-top:2px; background: #d6e6f3; margin-right:0px;box-sizing:border-box;',
}).append(
$('<div>', { style: 'display: flex; justify-content: flex-end; margin-bottom: 8px;' }).append(
$('<button>', {
class: 'form-control',
style: 'height: 26px; width:auto;padding: 2px 12px 2px 12px; background:#4d6a88;color:#eaf6ff;border:1px solid #4d6a88;font-weight:bold;border-radius:5px;',
})
.text('Reset')
.on('click', this.#onResetVisibleAtZoomClick.bind(this))
),
$('<div>').append(
$('<label>', { for: 'visible-at-zoom-input', style: 'font-size:14px;font-weight:bold;color:#4d6a88;' }).text('Visible at zoom:'),
(this.#visibleAtZoomInput = $('<input>', {
type: 'number',
id: 'visible-at-zoom-input',
min: this.#minVisibleAtZoom,
max: this.#maxVisibleAtZoom,
style: 'margin-left: 6px; width:46px;font-size:13px;border-radius:3px;',
}).change((v) => this.#onVisibleAtZoomChange(v)))
),
$('<div>', { style: 'font-size: 12.5px; color: #4d6a88; margin-top:5px;white-space:pre-line;text-align:left;' }).text(
'Pan or zoom the map to refresh after changing.\n\nSetting this value too low may cause performance issues.'
)
)
)
)
);
this.hide();
this._dialogDiv.appendTo('body');
if (typeof jQuery.ui !== 'undefined') {
const that = this;
this._dialogDiv.draggable({
stop() {
that._dialogDiv.css('height', '');
},
});
}
}
get gisLayer() {
return this.#gisLayer;
}
set gisLayer(value) {
if (value !== this.#gisLayer) {
this.#gisLayer = value;
this.#titleText.text(this.#gisLayer.name);
this.#initVisibleAtZoomInput();
}
}
#initVisibleAtZoomInput() {
this.#visibleAtZoomInput.val(getGisLayerVisibleAtZoom(this.#gisLayer));
}
getShiftAmount() {
return $('input[name=gisLayerShiftAmt]:checked').val();
}
show() {
this._dialogDiv.show();
}
hide() {
this._dialogDiv.hide();
}
#onResetVisibleAtZoomClick() {
settings.removeLayerSetting(this.#gisLayer.id, 'visibleAtZoom');
this.#initVisibleAtZoomInput();
}
#onCloseButtonClick() {
this.hide();
}
#onVisibleAtZoomChange() {
const min = this.#minVisibleAtZoom;
const max = this.#maxVisibleAtZoom;
let value = parseInt(this.#visibleAtZoomInput.val(), 10);
if (value < min) {
value = min;
this.#visibleAtZoomInput.val(value);
} else if (value > max) {
value = max;
this.#visibleAtZoomInput.val(value);
}
settings.setLayerSetting(this.#gisLayer.id, 'visibleAtZoom', value);
saveSettingsToStorage();
}
#onShiftButtonClick(x, y) {
const shiftAmount = this.getShiftAmount();
x *= shiftAmount;
y *= shiftAmount;
this.#shiftLayerFeatures(x, y);
const { id } = this.gisLayer;
let offset = settings.getLayerSetting(id, 'offset');
if (!offset) {
offset = { x: 0, y: 0 };
settings.setLayerSetting(id, 'offset', offset);
}
offset.x += x;
offset.y += y;
saveSettingsToStorage();
}
#onResetOffsetButtonClick() {
const offset = settings.getLayerSetting(this.gisLayer.id, 'offset');
if (offset) {
this.#shiftLayerFeatures(offset.x * -1, offset.y * -1);
settings.removeLayerSetting(this.gisLayer.id, 'offset');
saveSettingsToStorage();
}
}
#shiftLayerFeatures(x, y) {
//Given the inputs have been updated to Degrees, shifting by meters still makes sense and works.
const { isRoadLayer } = this.gisLayer;
let featureCollection = isRoadLayer ? roadFeatures : defaultFeatures;
const { distance, bearing } = LayerSettingsDialog.#calculateDistanceAndBearing(x, y);
featureCollection = featureCollection.filter((f) => f.properties.layerID === this.gisLayer.id).map((f) => turf.transformTranslate(f, distance, bearing, { units: 'meters' }));
if (isRoadLayer) {
roadFeatures = featureCollection;
} else {
defaultFeatures = featureCollection;
}
const layerName = isRoadLayer ? ROAD_LAYER_NAME : DEFAULT_LAYER_NAME;
const featureIds = featureCollection.map((f) => f.id);
sdk.Map.removeFeaturesFromLayer({ layerName, featureIds });
sdk.Map.addFeaturesToLayer({ layerName, features: featureCollection });
}
/**
* Calculates the total distance and bearing from X and Y meter offsets.
* @param {number} dx_meters - X offset in meters (east/west).
* @param {number} dy_meters - Y offset in meters (north/south).
* @returns {{distance: number, bearing: number}}
*/
static #calculateDistanceAndBearing(dx_meters, dy_meters) {
const distance = Math.sqrt(dx_meters ** 2 + dy_meters ** 2);
// Calculate bearing in radians
// Math.atan2(y, x) returns angle in radians between -PI and PI
// Need to adjust to be 0-360 degrees clockwise from North
const bearing_rad = Math.atan2(dx_meters, dy_meters); // dx_meters is 'x' (east), dy_meters is 'y' (north)
// Convert to degrees and adjust for 0-360, clockwise from North
let bearing_deg = bearing_rad * (180 / Math.PI);
bearing_deg = (bearing_deg + 360) % 360; // Ensure positive and within 0-360 range
return { distance, bearing: bearing_deg };
}
static #createShiftButton(fontAwesomeClass) {
return $('<button>', {
class: 'form-control',
style:
'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;',
}).append($('<i>', { class: 'fa', style: 'vertical-align: middle;font-size:16px;' }).addClass(fontAwesomeClass));
}
}
function loadSettingsFromStorage() {
const defaultSettings = {
lastVersion: '',
visibleLayers: [],
onlyShowApplicableLayers: false,
onlyShowApplicableLayersZoom: false,
selectedSubL1: [],
enabled: true,
fillParcels: false,
oneTimeAlerts: {},
layers: {},
shortcuts: {},
isPopupVisible: false,
useAcronyms: false,
useTitleCase: false,
useStateHwy: false,
removeNewLines: false,
collapsedSections: {},
layerGroups: {},
addrLabelDisplay: 'all',
socrataAppToken: '',
getLayerSetting: function () {
return undefined;
},
setLayerSetting: function () {},
removeLayerSetting: function () {},
};
let loadedSettings = {};
let migrated = false; // Track if any migration occurred
const storedSettings = localStorage.getItem(SETTINGS_STORE_NAME);
if (storedSettings) {
try {
const parsed = JSON.parse(storedSettings);
if (parsed && typeof parsed === 'object') {
loadedSettings = parsed;
} else {
logDebug(`Stored settings under key "${SETTINGS_STORE_NAME}" were not a valid object.`);
}
} catch (e) {
logError(`Failed to parse settings from localStorage key "${SETTINGS_STORE_NAME}":`, e);
}
}
// ---- MIGRATION: old selectedStates -> selectedSubL1 ----
if (loadedSettings.selectedStates && Array.isArray(loadedSettings.selectedStates)) {
if (!Array.isArray(loadedSettings.selectedSubL1)) loadedSettings.selectedSubL1 = [];
loadedSettings.selectedStates.forEach((stateCode) => {
const converted = `USA-${stateCode}`;
if (!loadedSettings.selectedSubL1.includes(converted)) {
loadedSettings.selectedSubL1.push(converted);
}
});
delete loadedSettings.selectedStates;
migrated = true;
logDebug('Migrated legacy selectedStates to selectedSubL1');
}
// --- MERGE with defaults ---
settings = { ...defaultSettings, ...loadedSettings };
// --- Save if migrated ---
if (migrated) {
saveSettingsToStorage();
logDebug('Settings saved after migration');
}
// --- Assign globals ---
isPopupVisible = settings.isPopupVisible;
useAcronyms = settings.useAcronyms;
useTitleCase = settings.useTitleCase;
useStateHwy = settings.useStateHwy;
removeNewLines = settings.removeNewLines;
// --- Utility layer functions ---
settings.getLayerSetting = function getLayerSetting(layerID, settingName) {
const layerSettings = this.layers[layerID];
if (!layerSettings) {
return undefined;
}
return layerSettings[settingName];
};
settings.setLayerSetting = function setLayerSetting(layerID, settingName, value) {
let layerSettings = this.layers[layerID];
if (!layerSettings) {
layerSettings = {};
this.layers[layerID] = layerSettings;
}
layerSettings[settingName] = value;
};
// Remove an individual setting or the entire layer if no settingName
settings.removeLayerSetting = function removeLayerSetting(layerID, settingName) {
if (typeof settingName === 'undefined') {
// Remove the entire layer settings block
delete this.layers[layerID];
} else {
const layerSettings = this.layers[layerID];
if (layerSettings) {
delete layerSettings[settingName];
// If the layerSettings object is now empty, remove the layer entirely
if (Object.keys(layerSettings).length === 0) {
delete this.layers[layerID];
}
}
}
};
// --- Legacy shortcut keys migration ---
if (settings.toggleHnsOnlyShortcut) {
settings.shortcuts.toggleHnsOnly = settings.toggleHnsOnlyShortcut;
delete settings.toggleHnsOnlyShortcut;
}
if (settings.toggleEnabledShortcut) {
settings.shortcuts.toggleEnabled = settings.toggleEnabledShortcut;
delete settings.toggleEnabledShortcut;
}
}
/**
* Saves current application settings and shortcut definitions to localStorage.
* Serializes the `settings` object and stores under the key `SETTINGS_STORE_NAME`.
*
* @typedef {Object} Shortcut
* @property {string} shortcutId - Unique identifier for the shortcut.
* @property {string} shortcutKeys - Key combination for activating the shortcut.
*
* @returns {void}
*/
function saveSettingsToStorage() {
settings.shortcuts = {};
/** @type {Shortcut[]} */
const shortcuts = sdk.Shortcuts.getAllShortcuts();
shortcuts.forEach(
/** @param {Shortcut} shortcut */
(shortcut) => {
settings.shortcuts[shortcut.shortcutId] = shortcut.shortcutKeys;
}
);
settings.lastVersion = scriptVersion;
settings.isPopupVisible = isPopupVisible;
settings.useAcronyms = useAcronyms;
settings.useTitleCase = useTitleCase;
settings.useStateHwy = useStateHwy;
settings.removeNewLines = removeNewLines;
localStorage.setItem(SETTINGS_STORE_NAME, JSON.stringify(settings));
logDebug('Settings saved');
}
/**
* Returns the maximum allowable offset (in degrees) for a given map zoom level.
* If no matching zoom level is found, uses the most detailed (22).
* @param {number} zoomLevel
* @returns {number}
*/
function getMaxAllowableOffsetForZoom(zoomLevel) {
const zoomToOffsetMap = {
12: 0.0009, // ~100 meters
13: 0.00045, // ~50 meters
14: 0.000225, // ~25 meters
15: 0.0001125, // ~12 meters
16: 0.000056, // ~6 meters
17: 0.000028, // ~3 meters
18: 0.000014, // ~1.5 meters
19: 0.000007, // ~1 meter
20: 0.000007, // ~1 meter
21: 0.000007, // ~1 meter
22: 0.000007, // ~1 meter
};
// Always round to nearest integer for lookup
const key = Math.round(zoomLevel);
return zoomToOffsetMap[key] !== undefined ? zoomToOffsetMap[key] : zoomToOffsetMap[22];
}
/**
* Build a feature query URL for a GIS layer given a bounding extent and zoom.
*
* @param {[number, number, number, number]} extent - [xmin, ymin, xmax, ymax] bounding box in EPSG:4326
* @param {GisLayer} gisLayer - Layer definition object
* @param {number} zoom - Display zoom level
* @returns {string} The fully constructed query URL, or '' on error
*/
function getUrl(extent, gisLayer, zoom) {
/**
* Utility: gets fields or returns empty array
* @param {unknown} fields
* @returns {string[]}
*/
const getFields = (fields) => (Array.isArray(fields) ? fields.slice() : []);
// ----- ArcGIS -----
if (gisLayer.platform === 'ArcGIS' || !gisLayer.platform) {
const layerOffset = settings.getLayerSetting(gisLayer.id, 'offset') ?? { x: 0, y: 0 };
const geometry = {
xmin: extent[0] - layerOffset.x,
ymin: extent[1] - layerOffset.y,
xmax: extent[2] - layerOffset.x,
ymax: extent[3] - layerOffset.y,
spatialReference: { wkid: 4326 },
};
const maxAllowableOffset = getMaxAllowableOffsetForZoom(zoom);
const fields = getFields(gisLayer.labelFields).join(',');
const params = [
`geometry=${encodeURIComponent(JSON.stringify(geometry))}`,
`outFields=${encodeURIComponent(fields)}`,
'returnGeometry=true',
'spatialRel=esriSpatialRelIntersects',
'geometryType=esriGeometryEnvelope',
'inSR=4326',
'outSR=4326',
'f=json',
`maxAllowableOffset=${maxAllowableOffset}`,
gisLayer.where ? `where=${encodeURIComponent(gisLayer.where)}` : '',
].filter(Boolean);
const url = `${gisLayer.url}/query?${params.join('&')}`;
logDebug(`ArcGIS Request URL: ${url}`);
return url;
}
//----- Socrata V2 and V3 -----
if (gisLayer.platform === 'SocrataV2' || gisLayer.platform === 'SocrataV3') {
const labelFields = getFields(gisLayer.labelFields);
if (labelFields.length === 0) {
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.");
return '';
}
const geomField = labelFields[0];
// Build bounding box with buffer (north, west, south, east)
const bufferDeg = 0.001;
const [xmin, ymin, xmax, ymax] = extent;
const boxClause = `within_box(${geomField},${ymax + bufferDeg},${xmin - bufferDeg},${ymin - bufferDeg},${xmax + bufferDeg})`;
const isNotNullClause = `${geomField} IS NOT NULL`;
// User WHERE (if any)
let customWhere = '';
if (typeof gisLayer.where === 'string' && gisLayer.where.trim()) {
customWhere = gisLayer.where.trim();
}
// ----- V2: SODA -----
if (gisLayer.platform === 'SocrataV2') {
const selectClause = labelFields.join(',');
const whereParts = [];
if (customWhere) whereParts.push(customWhere);
whereParts.push(boxClause);
whereParts.push(isNotNullClause);
const whereClause = whereParts.length ? `$where=${encodeURIComponent(whereParts.join(' AND '))}` : '';
const params = [`$select=${encodeURIComponent(selectClause)}`, whereClause, `$limit=3000`].filter(Boolean);
let urlBase = gisLayer.url + '.geojson';
const url = urlBase + '?' + params.join('&');
logDebug(`SocrataV2: Request URL: ${url}`);
return url;
}
// ----- V3: "SQL-in-query-param" pattern -----
if (gisLayer.platform === 'SocrataV3') {
// V3 only supports SQL-in-query, **not** SoQL style params.
// Build SQL string: SELECT ..., ... WHERE ... AND ... LIMIT ...
const selectFieldsList = labelFields.join(', ');
const whereParts = [];
if (customWhere) whereParts.push(customWhere);
whereParts.push(boxClause);
whereParts.push(isNotNullClause);
const whereSQL = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
const sql = `SELECT ${selectFieldsList} ${whereSQL} LIMIT 3000`;
let urlBase = gisLayer.url + '/query.geojson';
// NOTE: URL-encode the entire SQL string as the query's value
const url = `${urlBase}?query=${encodeURIComponent(sql)}`;
logDebug(`SocrataV3: Request URL: ${url}`);
return url;
}
}
// ----- Unknown -----
logDebug('getUrl fallback (no matching platform type found for:', gisLayer);
return '';
}
function hashString(value) {
let hash = 0;
if (value.length === 0) return hash;
for (let i = 0; i < value.length; i++) {
const chr = value.charCodeAt(i);
// eslint-disable-next-line no-bitwise
hash = (hash << 5) - hash + chr;
// eslint-disable-next-line no-bitwise
hash |= 0; // Convert to 32bit integer
}
return hash;
}
/**
* Retrieves the current map extent coordinates in the WGS84 projection.
*
* @param {'wgs84'|'CRS84'|'4326'|'EPSG:4326'} [projection='wgs84'] - Optional projection name
* (case-insensitive; allowed values: 'wgs84', 'CRS84', '4326', 'EPSG:4326').
* @returns {[number, number, number, number]} An array of [leftBottomLongitude, leftBottomLatitude, rightTopLongitude, rightTopLatitude] in WGS84.
* @throws {Error} If an unsupported projection type is specified.
*/
function getMapExtent(projection = 'wgs84') {
const wgs84Extent = sdk.Map.getMapExtent(); // [xmin, ymin, xmax, ymax] in WGS84
const wgs84Projections = ['wgs84', 'CRS84', '4326', 'EPSG:4326'];
if (wgs84Projections.includes(projection.toLowerCase())) {
return [wgs84Extent[0], wgs84Extent[1], wgs84Extent[2], wgs84Extent[3]];
} else {
throw new Error('Unsupported projection type');
}
}
/**
* Returns the "visibleAtZoom" setting for a GIS layer,
* considering layer settings overrides, the layer's own property,
* and falling back to a global default if neither is found.
*
* @param {GisLayer} gisLayer - The GIS layer configuration object.
* @returns {number} The zoom level at which the layer should be visible.
*/
function getGisLayerVisibleAtZoom(gisLayer) {
// Fetch override settings
const overrideVisibleAtZoom = settings.getLayerSetting(gisLayer.id, 'visibleAtZoom');
if (typeof overrideVisibleAtZoom === 'number') {
return overrideVisibleAtZoom;
}
const val = gisLayer.visibleAtZoom;
if (typeof val === 'number') {
return val;
}
return DEFAULT_VISIBLE_AT_ZOOM;
}
/**
* Calculates the zoom level at which labels for a GIS layer should become visible.
* If the layer has a non-null 'labelsVisibleAtZoom' property, computes the offset from the layer's 'visibleAtZoom'
* (with a fallback to a default if 'visibleAtZoom' is missing).
* Otherwise, defaults to 'layerVisibleAtZoom + 1'.
* Ensures the result is at least 1.
*
* @param {GisLayer} gisLayer - The GIS layer configuration object.
* @param {number} layerVisibleAtZoom - The zoom level at which the layer itself becomes visible.
* @returns {number} The computed zoom level at which the labels should be visible (>= 1).
*/
function getGisLayerLabelsVisibleAtZoom(gisLayer, layerVisibleAtZoom) {
layerVisibleAtZoom = +layerVisibleAtZoom;
if (gisLayer.labelsVisibleAtZoom != null) {
const baseVisibleAtZoom = gisLayer.visibleAtZoom != null ? +gisLayer.visibleAtZoom : DEFAULT_VISIBLE_AT_ZOOM;
let labelsVisibleAtZoom = layerVisibleAtZoom + (+gisLayer.labelsVisibleAtZoom - baseVisibleAtZoom);
if (labelsVisibleAtZoom < 1) labelsVisibleAtZoom = 1;
return labelsVisibleAtZoom;
} else {
let labelsVisibleAtZoom = layerVisibleAtZoom + 1;
if (labelsVisibleAtZoom < 1) labelsVisibleAtZoom = 1;
return labelsVisibleAtZoom;
}
}
/**
* Asynchronously determines which geographical regions are visible within the current map viewport.
*
* Retrieves the current map extent in WGS84, constructs a {@link ViewportBBox},
* and passes it to {@link WmeGisLBBOX.whatsInView} with high-precision intersection checks.
* The results are stored in the global (or upper-scope) `_whatsInView` variable, typed as {@link WhatsInViewResult}.
*
* Steps:
* 1. Gets current map extent in the "wgs84" coordinate system.
* 2. Converts extent into a {@link ViewportBBox} with properties `minLon`, `minLat`, `maxLon`, `maxLat`.
* 3. Calls {@link WmeGisLBBOX.whatsInView} with high-precision enabled and `returnGeoJson` disabled.
* 4. Stores the detailed intersecting regions in `_whatsInView`.
*
* @returns {Promise<void>} The results are assigned to `_whatsInView` (type: {@link WhatsInViewResult})
*/
async function whatsInView() {
const extentWgs84 = getMapExtent('wgs84');
const highPrecision = true;
const viewportBbox = {
minLon: extentWgs84[0],
minLat: extentWgs84[1],
maxLon: extentWgs84[2],
maxLat: extentWgs84[3],
};
/** @type {WhatsInViewResult} */
_whatsInView = await WmeGisLBBOX.whatsInView(viewportBbox, highPrecision, false);
}
/**
* Returns an array of fetchable GIS layers after applying multiple validation and filtering criteria.
*
* Checks performed include:
* - Minimum map zoom (from SDK) must be >= 12.
* - Layer must be enabled (`enabled === 1`).
* - Layer must have a non-empty and defined URL.
* - Layer's subdivision L1 (country/subL1) must match current settings selection.
* - If `checkVisibility` is true, the layer's ID must be present in the set of visible layers from settings.
* - If `checkZoomVisibility` is true, the layer must be visible for the current zoom level.
* - Layer must match a country/subdivision actually visible in the map view (from `_whatsInView`).
* - If the layer has subdivision level 2 (`subL2`), further filtered by active subdivision in view.
*
* @param {boolean} [checkVisibility=true] - If true, check whether each layer is visible in settings.
* @param {boolean} [checkZoomVisibility=true] - If true, filter layers by their zoom visibility constraints.
* @returns {GisLayer[]} Array of `GISLayer` objects that passed all checks and are eligible for fetching.
*/
function getFetchableLayers(checkVisibility = true, checkZoomVisibility = true) {
const zoom = sdk.Map.getZoomLevel();
// If zoom level is below 12, log a message and return an empty array, as layers won't be fetched
if (zoom < 12) {
logDebug(`No layers fetched, zoom level is < 12!`);
return [];
}
const fetchableLayers = []; // Array to hold fetchable layer IDs
// Filter the GIS layers based on multiple conditions to determine which are fetchable
const filteredLayers = _gisLayers.filter((gisLayer) => {
if (gisLayer.enabled !== 1) return false; // Check if the layer is enabled; skip it if not
// Ensure the layer has a valid URL; skip if it is empty or undefined
if (!gisLayer.url || gisLayer.url.trim().length === 0) return false;
// Check if the country subdivision level 1 is selected
if (!settings.selectedSubL1.includes(gisLayer.countrySubL1)) return false;
// Check if the layer ID is saved in settings as visible - turn off when call from "Only show applicable layers"
if (checkVisibility) {
if (!settings.visibleLayers.includes(gisLayer.id)) return false;
}
if (checkZoomVisibility) {
if (zoom < getGisLayerVisibleAtZoom(gisLayer)) return false; // Check if the layer is visible at the current zoom level
}
// Find the country data from the current view based on the ISO_ALPHA3 code
const countryData = Object.values(_whatsInView).find((countryData) => countryData.ISO_ALPHA3 === gisLayer.country);
if (!countryData) return false; // Skip if no matching country data is in view
// Check if the subdivision level 1 (subL1) is in view
const isSubL1InView = (gisLayer.subL1 && Object.values(countryData.subL1 || {}).some((subL1Data) => subL1Data.subL1_id === gisLayer.subL1)) || countryData.ISO_ALPHA3 === gisLayer.subL1;
if (!isSubL1InView) return false; // If subL1 is not in view, skip the layer
const hasSubL2 = gisLayer.subL2 && gisLayer.subL2.length > 0; // Check if the layer has subdivision level 2 names
if (hasSubL2) {
// Find the subdivision data entry that matches the layer's subL1 ID
const subL1DataEntry = Object.entries(countryData.subL1 || {}).find(([_, subL1Details]) => subL1Details.subL1_id === gisLayer.subL1);
const subL1Data = subL1DataEntry && subL1DataEntry[1]; // Retrieve the actual subL1 data object
if (!subL1Data) {
// If no matching subL1 data is found, skip the layer
return false;
}
// Check if any subL2 names from the layer match those in the subL1 data's subL2 list
const isSubL2InView = gisLayer.subL2.some((subL2Name) => subL1Data.subL2 && Object.keys(subL1Data.subL2).some((subL2InView) => subL2InView.toLowerCase() === subL2Name.toLowerCase()));
if (!isSubL2InView) return false; // If no subL2 matches are found, skip the layer
}
fetchableLayers.push(gisLayer.id); // If the layer passes all checks, add its ID to the fetchable layers list
return true;
});
return filteredLayers;
}
/**
* Updates the visibility of GIS layer checkboxes in the UI according to user-defined settings.
*
* Determines which GIS layers should be displayed using the current zoom level and visibility settings:
* - Shows checkboxes for layers deemed applicable by {@link getFetchableLayers}, which takes into account the current zoom setting from {@link settings.onlyShowApplicableLayersZoom}.
* - Alternatively, displays all layers if {@link settings.onlyShowApplicableLayers} is false, ignoring zoom-based filtering.
* - Hides unapplicable layers when both settings limit their display.
*
* Each layer's visibility is updated by showing or hiding the corresponding container element in the DOM.
*
* Side Effects:
* Mutates the UI to show or hide corresponding checkboxes and container elements for each GIS layer.
*
* @see getFetchableLayers
* @see settings
* @global {Array<GisLayer>} _gisLayers - The list of all GIS layer objects.
* @global {Object} settings - Application-wide layer filter and zoom settings.
* @global {function} $ - jQuery selector function to manipulate DOM elements.
*/
function filterLayerCheckboxes() {
const applicableLayers = getFetchableLayers(false, settings.onlyShowApplicableLayersZoom);
_gisLayers.forEach((gisLayer) => {
const layerContainerId = `#gis-layer-${gisLayer.id}-container`;
// Default behavior is to hide all layers
let showLayer = false;
// Show layer if it's included in applicable layers based on the zoom setting
if (applicableLayers.includes(gisLayer)) {
showLayer = true;
}
// Show all layers if onlyShowApplicableLayers setting is false
if (!settings.onlyShowApplicableLayers) {
showLayer = true;
}
// Apply visibility based on computed showLayer logic
if (showLayer) {
$(layerContainerId).show();
$(`#gis-layers-for-${gisLayer.subL1}`).show();
} else {
$(layerContainerId).hide();
$(`#gis-layers-for-${gisLayer.subL1}`).hide();
}
});
}
const ROAD_ABBR = [
[/\bAVENUE$/, 'AVE'],
[/\bCIRCLE$/, 'CIR'],
[/\bCOURT$/, 'CT'],
[/\bDRIVE$/, 'DR'],
[/\bLANE$/, 'LN'],
[/\bPARK$/, 'PK'],
[/\bPLACE$/, 'PL'],
[/\bROAD$/, 'RD'],
[/\bSTREET$/, 'ST'],
[/\bTERRACE$/, 'TER'],
];
/**
* @typedef {Object} LabelProcessingGlobals
* @property {typeof Number} Number
* @property {typeof Math} Math
* @property {typeof Boolean} Boolean
* @property {typeof parseInt} parseInt
* @property {typeof Date} Date
* @property {Object.<string, RegExp>} _regexReplace
* @property {object} [sdk]
*/
/** @type {LabelProcessingGlobals} */
const labelProcessingGlobalVariables = {
Number,
Math,
Boolean,
parseInt,
Date,
_regexReplace,
};
/**
* Processes and generates a display label for a feature/item, using layer label fields,
* zoom/area constraints, and optional ESTree/JS post-processing logic.
* Applies address and content shortening based on style rules and settings.
*
* @param {GisLayer} gisLayer - GIS layer descriptor (with labelFields, style, processLabel, and possibly labelProcessingError).
* @param {Object} item - The data source for the feature; may have `.attributes` (ArcGIS), `.properties` (GeoJSON), or fields directly.
* @param {number} displayLabelsAtZoom - Minimum zoom level at which labels are displayed.
* @param {number} area - Area of the feature in square meters (used for label display logic).
* @param {boolean} [isPolyLine=false] - If true, the label logic is specific to polylines.
* @returns {string} The processed label string for display (may be `''` if label is suppressed or error is present).
*/
function processLabel(gisLayer, item, displayLabelsAtZoom, area, isPolyLine = false) {
// --- Allow both ArcGIS and GeoJSON: resolve field source ---
// If the item has .attributes, use that (ArcGIS); else .properties (GeoJSON); fallback: item itself.
const fieldValues = item && typeof item === 'object' ? item.attributes || item.properties || item : {};
let label = '';
// --- Main label fields, only if zoom/area triggers label ---
if (sdk.Map.getZoomLevel() >= displayLabelsAtZoom || area >= 1000000) {
label +=
gisLayer.labelFields
?.map((fieldName) => fieldValues[fieldName])
.join(' ')
.trim() ?? '';
// --- Optional ESTree/JS post-processing if configured ---
if (gisLayer.processLabel) {
if (gisLayer.labelProcessingError) {
label = 'ERROR';
} else {
// Provide label and fields to processing context
const ctx = {
...labelProcessingGlobalVariables,
label,
fieldValues,
};
const result = ESTreeProcessor.execute(gisLayer.processLabel, ctx);
label = result.output?.trim() ?? '';
}
}
}
// --- Post-processing for certain styles (e.g., address shorteners) ---
if (!isPolyLine) {
if (label && ['points', 'parcels', 'state_points', 'state_parcels'].includes(gisLayer.style)) {
if (settings.addrLabelDisplay === 'hn') {
const m = label.match(/^\d+/);
label = m ? m[0] : '';
} else if (settings.addrLabelDisplay === 'street') {
const m = label.match(/^(?:\d+\s)?(.*)/);
label = m ? m[1].trim() : '';
} else if (settings.addrLabelDisplay === 'none') {
label = '';
}
}
}
return label;
}
let lastFeatureId = 0;
// SDK: Remove these once Map.getFeaturesByProperty is implemented: https://issuetracker.google.com/issues/419596843
let defaultFeatures = [];
let roadFeatures = [];
/**
* Offsets GeoJSON-like geometry coordinates by a layerOffset {x, y}.
* Supports: 'Point', 'LineString', 'MultiPoint', 'Polygon', 'MultiLineString', 'MultiPolygon'.
*
* @param {{ type: string, coordinates: any }} geometry - The geometry object.
* @param {{ x: number, y: number }} layerOffset - Offset to apply to all coordinates.
* @returns {Object} The offset geometry.
*/
function offsetGeometry(geometry, layerOffset) {
if (!geometry || !layerOffset) return geometry;
/**
* @param {[number, number]} coord
* @returns {[number, number]}
*/
function offsetCoord(coord) {
return [coord[0] + layerOffset.x, coord[1] + layerOffset.y];
}
switch (geometry.type) {
case 'Point':
// Safe to treat as [number, number]
return { ...geometry, coordinates: offsetCoord(geometry.coordinates) };
case 'LineString':
case 'MultiPoint':
// Array of [number, number]
return { ...geometry, coordinates: geometry.coordinates.map(offsetCoord) };
case 'Polygon':
case 'MultiLineString':
// Array of Array of [number, number]
return {
...geometry,
coordinates: geometry.coordinates.map(
/**
* @param {Array<[number, number]>} ring
*/
(ring) => ring.map(offsetCoord)
),
};
case 'MultiPolygon':
// Array of Array of Array of [number, number]
return {
...geometry,
coordinates: geometry.coordinates.map(
/**
* @param {Array<Array<[number, number]>>} poly
*/
(poly) =>
poly.map(
/**
* @param {Array<[number, number]>} ring
*/
(ring) => ring.map(offsetCoord)
)
),
};
default:
return geometry;
}
}
/**
* Clips the geometry of a LineString or MultiLineString feature to the given bounding box ([minX, minY, maxX, maxY]).
*
* For non-line features, the function returns the original input feature unchanged.
* If geometry is outside the bbox or the result is empty, returns null.
*
* @param {Object} feature - A GeoJSON Feature object, expected to have a LineString or MultiLineString geometry.
* @param {number[]} extent - Bounding box as [minX, minY, maxX, maxY].
* @returns {Object|null}
* Returns the clipped feature if successful and non-empty,
* otherwise returns null. For unsupported geometry types, returns the original feature.
*
* @example
* // Clip a geojson line
* clipLineFeatureToExtent(
* { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0,0],[10,10]] } },
* [2,2,8,8]
* )
*/
function clipLineFeatureToExtent(feature, extent) {
if (!feature.geometry || !extent) return feature;
const type = feature.geometry.type;
if (type !== 'LineString' && type !== 'MultiLineString') return feature;
try {
const clipped = turf.bboxClip(feature, extent);
// Ensure clipped geometry exists and has coordinates
if (!clipped.geometry.coordinates || !clipped.geometry.coordinates.length) return null;
return clipped;
} catch (e) {
return null;
}
}
function generateFeatureId() {
lastFeatureId++;
return lastFeatureId;
}
/**
* Assigns layer properties and an ID to a GeoJSON feature.
*
* Adds or overwrites the following properties of the input feature:
* - `properties.layerID`: set to `gisLayer.id`
* - `properties.label`: set to the provided label
* - `id`: set to a newly generated value from `generateFeatureId()`
*
* Modifies the input feature in-place and returns it.
*
* @param {Object} feature - A GeoJSON Feature object. Must have a `properties` field (object).
* @param {GisLayer} gisLayer - Layer object containing at least an `id` property.
* @param {string} label - The label to assign to the feature's properties.
* @returns {Object} The modified feature with updated properties and ID.
*
* @example
* const feature = { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [0, 0] } };
* const layer = { id: 'roads' };
* assignGisProperties(feature, layer, 'Highway');
* // => feature.properties.layerID === 'roads'
* // => feature.properties.label === 'Highway'
* // => feature.id is set
*/
function assignGisProperties(feature, gisLayer, label) {
feature.properties = {
...feature.properties,
layerID: gisLayer.id,
label,
};
feature.id = generateFeatureId();
return feature;
}
/**
* Deduplicates Point features within the given feature array that are spatially close (within 1 meter)
* and have labels. Merges labels of duplicates, applying label cleaning and abbreviation.
* Modifies the original features array in place and returns it.
*
* @param {Array} features - Array of GeoJSON features (with properties.label) to deduplicate.
* @returns {Array} The deduplicated (and possibly relabeled) features array.
*/
function deduplicatePointFeatures(features) {
for (let i = 0; i < features.length; i++) {
const f1 = features[i];
if (f1.geometry.type === 'Point' && !f1.skipDupeCheck && f1.properties.label) {
let labels = [f1.properties.label];
for (let j = i + 1; j < features.length; j++) {
const f2 = features[j];
if (f2.geometry.type === 'Point' && !f2.skipDupeCheck && f2.properties.label && turf.distance(f1, f2, { units: 'meters' }) < 1) {
features.splice(j, 1);
labels.push(f2.properties.label);
j--;
}
}
labels = _.uniq(labels);
if (labels.length > 1) {
labels.forEach((label, idx) => {
label = label
.replace(/\n/g, ' ')
.replace(/\s{2,}/, ' ')
.replace(/\bUNIT\s.{1,5}$/i, '')
.trim();
ROAD_ABBR.forEach((abbr) => (label = label.replace(abbr[0], abbr[1])));
labels[idx] = label;
});
labels = _.uniq(labels);
labels.sort();
if (labels.length > 12) {
const len = labels.length;
labels = labels.slice(0, 10);
labels.push(`(${len - 10} more...)`);
}
f1.properties.label = _.uniq(labels).join('\n');
} else {
let { label } = f1.properties;
ROAD_ABBR.forEach((abbr) => (label = label.replace(abbr[0], abbr[1])));
f1.properties.label = label;
}
}
}
return features;
}
/**
* Updates the given GIS map layer with a new set of features.
*
* - Removes all features belonging to the specified gisLayer from the appropriate global feature collection (`roadFeatures` or `defaultFeatures`).
* - Adds the new features to the map and collection.
* - Removes old features from the map layer.
* - Updates global feature arrays and sets label color in the UI.
*
* @param {GisLayer} gisLayer - GIS layer descriptor. Should have at least: `id`, `isRoadLayer`.
* @param {Object[]} features - Array of GeoJSON Feature objects to add to the layer.
*
* @returns {void}
*
* @example
* updateGisLayerFeatures({ id: 'main', isRoadLayer: false }, [myPointFeature, myLineFeature]);
*/
function updateGisLayerFeatures(gisLayer, features) {
const isRoad = gisLayer.isRoadLayer;
const layerName = isRoad ? ROAD_LAYER_NAME : DEFAULT_LAYER_NAME;
// Use the current set of features for this layer type
const sourceCollection = isRoad ? roadFeatures : defaultFeatures;
// Separate out features belonging to this layer vs. those not
const { featureIdsToRemove, remainingFeatures } = sourceCollection.reduce(
(acc, feature) => {
if (feature.properties.layerID === gisLayer.id) {
acc.featureIdsToRemove.push(feature.id);
} else {
acc.remainingFeatures.push(feature);
}
return acc;
},
{ featureIdsToRemove: [], remainingFeatures: [] }
);
// Add new features to the layer
sdk.Map.dangerouslyAddFeaturesToLayerWithoutValidation({ features, layerName });
// Remove old features from the layer
if (featureIdsToRemove.length > 0) {
sdk.Map.removeFeaturesFromLayer({ layerName, featureIds: featureIdsToRemove });
}
// Update the in-memory collections
const newCollection = [...remainingFeatures, ...features];
if (isRoad) {
roadFeatures = newCollection;
} else {
defaultFeatures = newCollection;
}
// Feedback in UI
if (features.length) {
$(`label[for="gis-layer-${gisLayer.id}"]`).css({ color: '#00a009' });
}
}
/**
* Processes and adds GIS features from ArcGIS data to the appropriate map layer.
*
* - Handles ArcGIS response objects containing features and/or error.
* - Supports Point, MultiPoint, Polygon, and Polyline geometries.
* - Applies offset as configured in layer settings.
* - Assigns feature properties and labels.
* - Applies de-duplication for Points.
* - Updates in-memory/global feature collections.
* - Manages UI state/feedback for errors and successes.
* - Aborts all work if `token.cancel` is true at key moments.
*
* @param {Object} data - ArcGIS response object. Should include `.features` (Array) and/or `.error`.
* @param {Object} token - Cancellation token/object. If `token.cancel === true`, aborts processing.
* @param {GisLayer} gisLayer - GIS layer descriptor. Should have at least: `id`, `isRoadLayer`, `name`.
*
* @returns {void}
*
* @example
* // Usage:
* processFeaturesArcGIS(
* { features: [ { geometry: { x: 1, y: 2 } } ] },
* { cancel: false },
* { id: 'roads', isRoadLayer: true, name: 'Streets' }
* );
*/
function processFeaturesArcGIS(data, token, gisLayer) {
const features = [];
if (data.skipIt) return;
if (data.error) {
logError(`Error in layer "${gisLayer.name}": ${data.error.message}`);
$(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
return;
}
const items = data.features || [];
const layerOffset = settings.getLayerSetting(gisLayer.id, 'offset') ?? { x: 0, y: 0 };
const extent = getMapExtent('wgs84');
const displayLabelsAtZoom = getGisLayerLabelsVisibleAtZoom(gisLayer, getGisLayerVisibleAtZoom(gisLayer));
if (!token.cancel) {
let error = false;
items.forEach((item) => {
if (token.cancel || error) return;
if (!item.geometry) return;
//---------- POINT ----------
if (item.geometry.x !== undefined && item.geometry.y !== undefined) {
let feature = turf.point([item.geometry.x, item.geometry.y]);
feature.geometry = offsetGeometry(feature.geometry, layerOffset);
feature = assignGisProperties(feature, gisLayer, processLabel(gisLayer, item, displayLabelsAtZoom, '', false));
if (isPopupVisible) addLabelToLayer(gisLayer.name, feature.properties.label);
features.push(feature);
//---------- MULTI-POINT ----------
} else if (item.geometry.points) {
item.geometry.points.forEach((point) => {
let feature = turf.point(point);
feature.geometry = offsetGeometry(feature.geometry, layerOffset);
feature = assignGisProperties(feature, gisLayer, processLabel(gisLayer, item, displayLabelsAtZoom, '', false));
if (isPopupVisible) addLabelToLayer(gisLayer.name, feature.properties.label);
features.push(feature);
});
//---------- POLYGON ----------
} else if (item.geometry.rings) {
const separatePolygons = [];
let currentOuterRing = null;
const innerRings = [];
item.geometry.rings.forEach((ringIn) => {
const ring = ringIn.map(([x, y]) => [x + layerOffset.x, y + layerOffset.y]);
if (turf.booleanClockwise(ring)) {
if (currentOuterRing) {
separatePolygons.push({ outer: currentOuterRing, inners: [...innerRings] });
}
currentOuterRing = ring;
innerRings.length = 0;
} else {
innerRings.push(ring);
}
});
if (currentOuterRing) {
separatePolygons.push({ outer: currentOuterRing, inners: [...innerRings] });
}
separatePolygons.forEach(({ outer, inners }) => {
const polygonRings = [outer, ...inners];
let feature = turf.polygon(polygonRings);
const area = turf.area(feature);
feature = assignGisProperties(feature, gisLayer, processLabel(gisLayer, item, displayLabelsAtZoom, area, false));
if (isPopupVisible) addLabelToLayer(gisLayer.name, feature.properties.label);
features.push(feature);
});
//---------- LINES / POLYLINE ----------
} else if (data.geometryType === 'esriGeometryPolyline' && item.geometry.paths) {
item.geometry.paths.forEach((path) => {
const offsetPath = path.map(([x, y]) => [x + layerOffset.x, y + layerOffset.y]);
let feature = turf.lineString(offsetPath);
feature = clipLineFeatureToExtent(feature, extent) || null;
if (!feature) return;
feature = assignGisProperties(feature, gisLayer, processLabel(gisLayer, item, displayLabelsAtZoom, '', true));
feature.skipDupeCheck = true;
if (isPopupVisible) addLabelToLayer(gisLayer.name, feature.properties.label);
features.push(feature);
});
//---------- UNKNOWN / ERROR ----------
} else {
logDebug(`Unexpected feature type in layer: ${JSON.stringify(item)}`);
logError(`Error: Unexpected feature type in layer "${gisLayer.name}"`);
$(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
error = true;
}
});
}
// ----- De-duplication and feature management -----
if (!token.cancel) {
// Only deduplicate if any Point features are present
if (features.some((f) => f.geometry.type === 'Point')) {
deduplicatePointFeatures(features);
}
// Layer/collection logic handled by helper
updateGisLayerFeatures(gisLayer, features);
}
}
/**
* Processes and adds features from a GeoJSON FeatureCollection or Feature array
* to the appropriate GIS map layer. Handles geometry flattening, feature offsetting,
* line clipping, label assignment, and deduplication. Updates global feature
* collections and provides UI feedback.
*
* @param {Object} data - The GeoJSON response data with a 'features' array, and possible 'error' and 'skipIt'.
* @param {Object} token - Cancellation/scoping token; if token.cancel is true, processing is aborted.
* @param {GisLayer} gisLayer - The layer descriptor object (should include at least id, name, isRoadLayer).
*
* @returns {void}
*/
function processFeaturesGeoJSON(data, token, gisLayer) {
const features = [];
if (data.skipIt) return;
if (data.error) {
logError(`Error in layer "${gisLayer.name}": ${data.error.message}`);
$(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
return;
}
const items = data.features || [];
const layerOffset = settings.getLayerSetting(gisLayer.id, 'offset') ?? { x: 0, y: 0 };
const extent = getMapExtent('wgs84'); // [minX, minY, maxX, maxY]
const displayLabelsAtZoom = getGisLayerLabelsVisibleAtZoom(gisLayer, getGisLayerVisibleAtZoom(gisLayer));
if (!token.cancel) {
let error = false;
items.forEach((item) => {
if (token.cancel || error) return;
if (!item.geometry) return;
// Always GeoJSON feature. Use turf.flatten to ensure individual features.
// flatten returns a FeatureCollection, so we need to iterate over .features
// But "flatten" expects a Feature or FeatureCollection, so ensure type.
let toFlatten = item;
if (toFlatten.type !== 'Feature') {
toFlatten = {
type: 'Feature',
geometry: item.geometry,
properties: item.properties || {},
};
}
const flatFeatures = turf.flatten(toFlatten).features;
flatFeatures.forEach((feature) => {
// Always offset geometry!
feature.geometry = offsetGeometry(feature.geometry, layerOffset);
// --- CLIP LINES TO EXTENT for LineString ---
if (feature.geometry.type === 'LineString') {
feature = clipLineFeatureToExtent(feature, extent) || null;
if (!feature) return; // If fully outside, skip
}
// Calculate area for polygons (only needed for label)
let area = '';
if (feature.geometry.type === 'Polygon') {
area = turf.area(feature);
}
feature = assignGisProperties(feature, gisLayer, processLabel(gisLayer, feature, displayLabelsAtZoom, area, feature.geometry.type === 'LineString'));
if (isPopupVisible) addLabelToLayer(gisLayer.name, feature.properties.label);
features.push(feature);
});
});
}
// ----- De-duplication and feature management -----
if (!token.cancel) {
// Only deduplicate if any Point features are present
if (features.some((f) => f.geometry.type === 'Point')) {
deduplicatePointFeatures(features);
}
// Layer/collection logic handled by helper
updateGisLayerFeatures(gisLayer, features);
}
}
function copyTextToClipboard(text) {
try {
GM_setClipboard(text);
logDebug(`Copy Text To Clipboard: ${text}`);
} catch (err) {
logError(`Failed to Text To Clipboard: ${err}`);
}
}
function addLabelToLayer(layerName, label) {
if (!layerLabels[layerName]) {
layerLabels[layerName] = new Set();
}
layerLabels[layerName].add(label);
}
function replacePhrasesWithAcronyms(text) {
// Order phrases such that compound phrases come before individual words
const replacements = [
// compound phrases here
{ phrase: 'Alternate Route', acronym: 'ALT' },
{ phrase: 'Army Air Field', acronym: 'AAF' },
{ phrase: 'County Highway', acronym: 'CH-' },
{ phrase: 'County Road', acronym: 'CR-' },
{ phrase: 'East Bound', acronym: 'EB' },
{ phrase: 'North Bound', acronym: 'NB' },
{ phrase: 'North East', acronym: 'NE' },
{ phrase: 'North West', acronym: 'NW' },
{ phrase: 'South Bound', acronym: 'SB' },
{ phrase: 'South East', acronym: 'SE' },
{ phrase: 'South West', acronym: 'SW' },
{ phrase: 'State Highway', acronym: 'SH-' },
{ phrase: 'State Route', acronym: 'SR-' },
{ phrase: 'State Rte', acronym: 'SR-' },
{ phrase: 'U.S. Highway', acronym: 'US-' },
{ phrase: 'U.S. Route', acronym: 'US-' },
{ phrase: 'U.S. Rte', acronym: 'US-' },
{ phrase: 'U.S.Rte', acronym: 'US-' },
{ phrase: 'US Highway', acronym: 'US-' },
{ phrase: 'U S Highway', acronym: 'US-' },
{ phrase: 'US Route', acronym: 'US-' },
{ phrase: 'U S Route', acronym: 'US-' },
{ phrase: 'US RTE', acronym: 'US-' },
{ phrase: 'U S RTE', acronym: 'US-' },
{ phrase: 'USRTE', acronym: 'US-' },
{ phrase: 'West Bound', acronym: 'WB' },
// Start of single words list
{ phrase: 'Alley', acronym: 'Aly' },
{ phrase: 'Apartments', acronym: 'Apts' },
{ phrase: 'Avenue', acronym: 'Ave' },
{ phrase: 'Beach', acronym: 'Bch' },
{ phrase: 'Boulevard', acronym: 'Blvd' },
{ phrase: 'Bridge', acronym: 'Br' },
{ phrase: 'Business', acronym: 'BUS' },
{ phrase: 'Bypass', acronym: 'BYP' },
{ phrase: 'Canyon', acronym: 'Cyn' },
{ phrase: 'Captain', acronym: 'Capt' },
{ phrase: 'Causeway', acronym: 'Cswy' },
{ phrase: 'Center', acronym: 'Ctr' },
{ phrase: 'Circle', acronym: 'Cir' },
{ phrase: 'Colonel', acronym: 'Col.' },
{ phrase: 'Commander', acronym: 'Cmdr.' },
{ phrase: 'Connector', acronym: 'CONN' },
{ phrase: 'Corners', acronym: 'Cors' },
{ phrase: 'Corporal', acronym: 'Cpl.' },
{ phrase: 'Court', acronym: 'Ct' },
{ phrase: 'Cove', acronym: 'Cv' },
{ phrase: 'Creek', acronym: 'Crk' },
{ phrase: 'Crescent', acronym: 'Cres' },
{ phrase: 'Crossing', acronym: 'X-ing' },
{ phrase: 'Doctor', acronym: 'Dr.' },
{ phrase: 'Drive', acronym: 'Dr' },
{ phrase: 'East', acronym: 'E' },
{ phrase: 'Eastbound', acronym: 'EB' },
{ phrase: 'Eb', acronym: 'EB' },
{ phrase: 'Express', acronym: 'EXP' },
{ phrase: 'Expressway', acronym: 'Expwy' },
{ phrase: 'Extension', acronym: 'Ext' },
{ phrase: 'Fort', acronym: 'Ft.' },
{ phrase: 'Freeway', acronym: 'Fwy' },
{ phrase: 'General', acronym: 'Gen.' },
{ phrase: 'Governor', acronym: 'Gov.' },
{ phrase: 'Grove', acronym: 'Grv' },
{ phrase: 'Heights', acronym: 'Hts' },
{ phrase: 'Highway', acronym: 'Hwy' },
{ phrase: 'Honerable', acronym: 'Hon.' },
{ phrase: 'International', acronym: 'Intl' },
{ phrase: 'Interstate', acronym: 'I-' },
{ phrase: 'Junior', acronym: 'Jr.' },
{ phrase: 'Landing', acronym: 'Lndg' },
{ phrase: 'Lane', acronym: 'Ln' },
{ phrase: 'Lieutenant', acronym: 'Lt.' },
{ phrase: 'Loop', acronym: 'Lp' },
{ phrase: 'Major', acronym: 'Maj.' },
{ phrase: 'Manor', acronym: 'Mnr.' },
{ phrase: 'Meadow', acronym: 'Mdw' },
{ phrase: 'Mount', acronym: 'Mt' },
{ phrase: 'Mountain', acronym: 'Mtn' },
{ phrase: 'Mountains', acronym: 'Mtns' },
{ phrase: 'National', acronym: "Nat'l" },
{ phrase: 'North', acronym: 'N' },
{ phrase: 'Northbound', acronym: 'NB' },
{ phrase: 'Nb', acronym: 'NB' },
{ phrase: 'Northeast', acronym: 'NE' },
{ phrase: 'Northwest', acronym: 'NW' },
{ phrase: 'Park', acronym: 'Pk' },
{ phrase: 'Parkway', acronym: 'Pkwy' },
{ phrase: 'Parkways', acronym: 'Pkwys' },
{ phrase: 'Passage', acronym: 'Psge' },
{ phrase: 'Place', acronym: 'Pl' },
{ phrase: 'Plaza', acronym: 'Plz' },
{ phrase: 'Point', acronym: 'Pt' },
{ phrase: 'Points', acronym: 'Pts' },
{ phrase: 'President', acronym: 'Pres.' },
{ phrase: 'Professor', acronym: 'Prof.' },
{ phrase: 'Railroad', acronym: 'R.R.' },
{ phrase: 'Road', acronym: 'Rd' },
{ phrase: 'Recreational', acronym: 'Rec.' },
{ phrase: 'Reverend', acronym: 'Rev.' },
{ phrase: 'Route', acronym: 'SR-' },
{ phrase: 'Saint', acronym: 'St.' },
{ phrase: 'Sainte', acronym: 'Ste.' },
{ phrase: 'Senior', acronym: 'Sr.' },
{ phrase: 'Sergeant', acronym: 'Sgt.' },
{ phrase: 'Skyway', acronym: 'Skwy' },
{ phrase: 'South', acronym: 'S' },
{ phrase: 'Southbound', acronym: 'SB' },
{ phrase: 'Sb', acronym: 'SB' },
{ phrase: 'Southeast', acronym: 'SE' },
{ phrase: 'Southwest', acronym: 'SW' },
{ phrase: 'Springs', acronym: 'Spgs' },
{ phrase: 'Square', acronym: 'Sq' },
{ phrase: 'Station', acronym: 'Sta' },
{ phrase: 'Street', acronym: 'St' },
{ phrase: 'Terrace', acronym: 'Ter' },
{ phrase: 'Throughway', acronym: 'Thwy' },
{ phrase: 'Thruway', acronym: 'Thwy' },
{ phrase: 'Tollway', acronym: 'Tlwy' },
{ phrase: 'Township', acronym: 'Twp' },
{ phrase: 'Trafficway', acronym: 'Trfy' },
{ phrase: 'Trail', acronym: 'Trl' },
{ phrase: 'Tunnel', acronym: 'Tun' },
{ phrase: 'Turnpike', acronym: 'Tpk' },
{ phrase: 'Upper', acronym: 'Upr' },
{ phrase: 'U.S.', acronym: 'US' },
{ phrase: 'Valley', acronym: 'Vly' },
{ phrase: 'West', acronym: 'W' },
{ phrase: 'Westbound', acronym: 'WB' },
{ phrase: 'Wb', acronym: 'WB' },
{ phrase: '--', acronym: '-' },
{ phrase: ' -', acronym: '-' },
{ phrase: '- ', acronym: '-' },
{ phrase: '- -', acronym: '-' },
];
let updatedText = text;
// Replace phrases with their acronyms, case insensitive
replacements.forEach(({ phrase, acronym }) => {
const regex = new RegExp(`\\b${phrase}\\b`, 'gi'); // Uses \\b to match words with word boundaries
updatedText = updatedText.replace(regex, acronym);
});
return updatedText;
}
function fixSateHwyRoadNames(text) {
// Regular expression to capture patterns like "XXX ###", "XXX-###", "XXX###", as well as "Us Route #", "Us Rte #", and "Route #", "Rte #"
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;
// Replacement function formats matched patterns
return text.replace(regex, (match, letters, numbers, usRouteNumber, rteNumber, routeNumber) => {
if (usRouteNumber) {
return `US-${usRouteNumber}`; // for "US Route"/s
}
if (rteNumber || routeNumber) {
return `SR-${rteNumber || routeNumber}`; // Change "Rte" or "Route" to "SR"
}
if (letters && numbers) {
return `${letters.toUpperCase()}-${numbers}`; // General format for other letter-number combos
}
return match;
});
}
function titleCaseLabel(text) {
// Read each line separately
const lines = text.split('\n');
return lines
.map((line) => {
const trimmedLine = line.trim(); // Trim the line to remove leading/trailing spaces
const words = trimmedLine.split(' '); // Split the line into individual words
// Capitalize the first letter of each word and convert the rest to lowercase
const titleCasedLine = words.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' '); // Recombine the words into a title-cased line
return titleCasedLine; // Return the formatted line
})
.join('\n'); // Combine all the lines back into a single string separated by new lines
}
function processedLabel(label) {
if (useTitleCase) {
label = titleCaseLabel(label);
}
if (useAcronyms) {
label = replacePhrasesWithAcronyms(label);
}
if (useStateHwy) {
label = fixSateHwyRoadNames(label);
}
if (removeNewLines) {
label = label.replace(/[\r\n]+/g, ' ');
}
return label;
}
function updatePopup(labels) {
let popup = document.getElementById('layerLabelPopup');
if (!popup) {
popup = document.createElement('div');
popup.id = 'layerLabelPopup';
popup.style = `position: absolute; background: #d3d3d3; border: 2px solid #007bff; border-radius: 5px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); z-index: 1000; width: 500px; max-width: 800px;
height: 300px; resize: both; overflow: hidden; max-height: 700px; left: ${popupPosition.left}; top: ${popupPosition.top}; `;
const header = document.createElement('div');
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; ';
const title = document.createElement('span');
title.innerText = 'GIS-L Layer Labels';
header.appendChild(title);
const closeButton = document.createElement('span');
closeButton.innerText = '×';
closeButton.style = 'cursor: pointer; font-size: 20px; margin-left: 10px; ';
closeButton.addEventListener('click', () => {
isPopupVisible = false;
togglePopupVisibility();
$('input[name="popupVisibility"][value="show"]').prop('checked', isPopupVisible);
$('input[name="popupVisibility"][value="hide"]').prop('checked', !isPopupVisible);
saveSettingsToStorage();
});
header.appendChild(closeButton);
popup.appendChild(header);
const formatOptionContainer = document.createElement('div');
formatOptionContainer.style = 'background: #72767d; color: #fff;';
const firstRow = document.createElement('div');
firstRow.style = 'display: flex; gap: 10px; align-items: flex-start; justify-content: flex-start;';
const formatCheckbox = document.createElement('input');
formatCheckbox.type = 'checkbox';
formatCheckbox.id = 'useTitleCaseCheckbox';
formatCheckbox.style = 'margin-left: 10px';
formatCheckbox.checked = useTitleCase;
formatCheckbox.addEventListener('change', () => {
useTitleCase = formatCheckbox.checked;
updatePopupContent(labels);
saveSettingsToStorage();
});
firstRow.appendChild(formatCheckbox);
const formatCheckboxLabel = document.createElement('label');
formatCheckboxLabel.htmlFor = 'useTitleCaseCheckbox';
formatCheckboxLabel.innerText = 'Use Title Case';
formatCheckboxLabel.style = 'font-weight: 100; width: 150px;';
firstRow.appendChild(formatCheckboxLabel);
const acronymCheckbox = document.createElement('input');
acronymCheckbox.type = 'checkbox';
acronymCheckbox.id = 'useacronymsCheckbox';
acronymCheckbox.checked = useAcronyms;
acronymCheckbox.addEventListener('change', () => {
useAcronyms = acronymCheckbox.checked;
updatePopupContent(labels);
saveSettingsToStorage();
});
firstRow.appendChild(acronymCheckbox);
const acronymCheckboxLabel = document.createElement('label');
acronymCheckboxLabel.htmlFor = 'useacronymsCheckbox';
acronymCheckboxLabel.innerText = 'Use Acronyms & Abbreviations';
acronymCheckboxLabel.style = 'font-weight: 100;';
firstRow.appendChild(acronymCheckboxLabel);
formatOptionContainer.appendChild(firstRow);
const secondRow = document.createElement('div');
secondRow.style = 'display: flex; gap: 10px; align-items: flex-start; justify-content: flex-start;';
const stateHwyCheckbox = document.createElement('input');
stateHwyCheckbox.type = 'checkbox';
stateHwyCheckbox.id = 'useStateHwyCheckbox';
stateHwyCheckbox.style = 'margin-left: 10px';
stateHwyCheckbox.checked = useStateHwy;
stateHwyCheckbox.addEventListener('change', () => {
useStateHwy = stateHwyCheckbox.checked;
updatePopupContent(labels);
saveSettingsToStorage();
});
secondRow.appendChild(stateHwyCheckbox);
const stateHwyCheckboxLabel = document.createElement('label');
stateHwyCheckboxLabel.htmlFor = 'useStateHwyCheckbox';
stateHwyCheckboxLabel.innerText = 'Fix Highway Labels';
stateHwyCheckboxLabel.style = 'font-weight: 100; width: 150px;';
secondRow.appendChild(stateHwyCheckboxLabel);
const removeNewLinesCheckbox = document.createElement('input');
removeNewLinesCheckbox.type = 'checkbox';
removeNewLinesCheckbox.id = 'removeNewLinesCheckbox';
removeNewLinesCheckbox.checked = removeNewLines;
removeNewLinesCheckbox.addEventListener('change', () => {
removeNewLines = removeNewLinesCheckbox.checked;
updatePopupContent(labels);
saveSettingsToStorage();
});
secondRow.appendChild(removeNewLinesCheckbox);
const removeNewLinesCheckboxLabel = document.createElement('label');
removeNewLinesCheckboxLabel.htmlFor = 'removeNewLinesCheckbox';
removeNewLinesCheckboxLabel.innerText = 'Remove New Lines';
removeNewLinesCheckboxLabel.style = 'font-weight: 100;';
secondRow.appendChild(removeNewLinesCheckboxLabel);
formatOptionContainer.appendChild(secondRow);
popup.appendChild(formatOptionContainer);
const dropdownContainer = document.createElement('div');
dropdownContainer.style = 'margin-bottom: 10px;';
popup.appendChild(dropdownContainer);
const contentContainer = document.createElement('div');
contentContainer.style = 'padding: 5px; overflow-y: auto; overflow-x: auto; height: calc(100% - 110px);';
popup.appendChild(contentContainer);
const mapElement = document.getElementsByTagName('wz-page-content')[0];
if (mapElement) {
mapElement.appendChild(popup);
}
header.onmousedown = function (event) {
event.preventDefault();
const parentRect = mapElement.getBoundingClientRect();
const initialX = event.clientX;
const initialY = event.clientY;
const offsetX = initialX - parentRect.left - popup.offsetLeft;
const offsetY = initialY - parentRect.top - popup.offsetTop;
document.onmousemove = function (ev) {
popup.style.left = `${ev.clientX - offsetX - parentRect.left}px`;
popup.style.top = `${ev.clientY - offsetY - parentRect.top}px`;
popupPosition.left = popup.style.left;
popupPosition.top = popup.style.top;
};
document.onmouseup = function () {
document.onmousemove = null;
document.onmouseup = null;
};
};
}
updatePopupContent(labels);
popup.style.display = isPopupVisible ? 'block' : 'none';
}
function updatePopupContent(labels) {
const dropdownContainer = document.querySelector('#layerLabelPopup div:nth-child(3)');
const contentContainer = document.querySelector('#layerLabelPopup div:nth-child(4)');
dropdownContainer.innerHTML = '';
contentContainer.innerHTML = '';
const select = document.createElement('select');
select.style = 'width: 100%; padding: 5px; border: 1px solid #ccc;';
const sortedLayerNames = Object.keys(labels).sort();
sortedLayerNames.forEach((layerName) => {
const option = document.createElement('option');
option.value = layerName;
option.innerText = layerName;
select.appendChild(option);
const uniqueLabels = Array.from(labels[layerName]).sort();
const tabContent = document.createElement('div');
tabContent.style = 'display: none; width: 100%; white-space: pre;';
const processedLabels = uniqueLabels
.map((label) => {
const text = processedLabel(label);
const copyIcon = '<span style="cursor: pointer; margin-left: 5px;" title="Copy to clipboard">📋</span>';
return `<li style="margin-bottom: 0.3em; color: #000000;" data-label="${text}">${text}${copyIcon}</li>`;
})
.join('');
tabContent.innerHTML = `<ul style="padding-left: 20px; margin-top: 0;">${processedLabels}</ul>`;
contentContainer.appendChild(tabContent);
// Add copying functionality
tabContent.querySelectorAll('li').forEach((li) => {
const icon = li.querySelector('span');
if (icon) {
icon.addEventListener('click', () => {
const textToCopy = li.getAttribute('data-label'); // Get the text from a custom data attribute
copyTextToClipboard(textToCopy);
});
}
});
});
dropdownContainer.appendChild(select);
let selectedLayerIndex = sortedLayerNames.indexOf(popupActiveLayer);
if (selectedLayerIndex === -1 && select.options.length > 0) {
selectedLayerIndex = 0;
popupActiveLayer = sortedLayerNames[selectedLayerIndex];
}
select.selectedIndex = selectedLayerIndex;
const allContents = contentContainer.querySelectorAll('div');
allContents.forEach((content, index) => {
content.style.display = index === select.selectedIndex ? 'block' : 'none';
});
select.addEventListener('change', () => {
const contents = contentContainer.querySelectorAll('div');
contents.forEach((content, index) => {
content.style.display = index === select.selectedIndex ? 'block' : 'none';
});
popupActiveLayer = select.value;
});
}
/**
* Asynchronously fetches GIS features for visible, user-selected map layers, based on current viewport and settings.
*
* Functionality:
* - Clears existing feature labels if a popup is visible, then returns early if fetching is disabled or zoom is below threshold.
* - Determines which map layers are both fetchable and visible, and removes features for layers not being fetched.
* - Updates layer checkbox UI and logs intended fetch actions.
* - For each eligible GIS layer:
* - Assembles an HTTP GET request (supports ArcGIS and Socrata platforms).
* - Handles required API tokens and warns about missing tokens for relevant platforms.
* - On successful response, delegates to the right feature processing function,
* updates features, tracks per-layer processing, and updates the popup if needed.
* - Logs and handles errors robustly (parsing, HTTP, platform, etc), including explicit UI feedback.
*
* Notes:
* - The function leverages global application state for layers, map zoom, in-memory features, and UI feedback.
* - Relies on helper functions and several external APIs (e.g., `sdk.Map`, `GM_xmlhttpRequest`, jQuery).
* - Non-blocking: each layer fetch is asynchronous and processed independently.
*
* Error Handling:
* - Logs parsing and HTTP errors with details.
* - Sets UI labels to red for layers with errors or parsing issues.
* - Alerts user if required API tokens are missing.
*
* Side Effects:
* - Updates global feature collections (e.g., `roadFeatures`, `defaultFeatures`), label maps, popup contents, and UI highlighting.
*
* @async
* @returns {Promise<void>} Does not resolve to a value. Operates via side effects on global state, the map, and the UI.
*
* @example
* // Usually called without parameters, in response to map move/zoom or UI change:
* await fetchFeatures();
*/
async function fetchFeatures() {
// 1. Clear labels if popup is open
if (isPopupVisible) {
Object.keys(layerLabels).forEach((key) => delete layerLabels[key]);
}
if (ignoreFetch) return;
if (sdk.Map.getZoomLevel() < 12) return;
await whatsInView();
lastToken.cancel = true;
lastToken = { cancel: false, features: [], layersProcessed: 0 };
$('.gis-subL1-layer-label').css({});
let _layersCleared = false;
let layersToFetch = [];
// 2. Prepare and clear features for layers not being fetched
if (!_layersCleared) {
_layersCleared = true;
layersToFetch = getFetchableLayers(true, true);
_gisLayers.forEach((gisLayer) => {
if (!layersToFetch.includes(gisLayer)) {
let featureCollection = gisLayer.isRoadLayer ? roadFeatures : defaultFeatures;
const layerName = gisLayer.isRoadLayer ? ROAD_LAYER_NAME : DEFAULT_LAYER_NAME;
const featureIds = featureCollection.filter((f) => f.properties.layerID === gisLayer.id).map((f) => f.id);
if (featureIds.length) {
sdk.Map.removeFeaturesFromLayer({ layerName, featureIds });
featureCollection = featureCollection.filter((f) => !featureIds.includes(f.id));
if (gisLayer.isRoadLayer) {
roadFeatures = featureCollection;
} else {
defaultFeatures = featureCollection;
}
}
}
});
}
filterLayerCheckboxes();
logDebug(`Fetching ${layersToFetch.length} layers...`, layersToFetch);
let layersProcessedCount = 0;
const extentWGS84 = getMapExtent('wgs84');
const zoom = sdk.Map.getZoomLevel();
// 3. Fetch features per-layer
layersToFetch.forEach((gisLayer) => {
const url = getUrl(extentWGS84, gisLayer, zoom);
// Build headers if needed
/** @type {Object.<string, string>} */
const headers = {};
const appToken = settings.socrataAppToken ? settings.socrataAppToken.trim() : '';
const isSocrata = gisLayer.platform === 'SocrataV2' || gisLayer.platform === 'SocrataV3';
if (isSocrata && appToken) {
headers['X-App-Token'] = appToken;
}
if (gisLayer.platform === 'SocrataV3' && !appToken) {
logDebug(`Socrata V3 layer "${gisLayer.id}" requires an App Token, but none was provided.`);
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.`);
return;
}
GM_xmlhttpRequest({
url,
headers,
context: lastToken,
method: 'GET',
onload(res2) {
if (res2.status < 400) {
try {
const parsedData = $.parseJSON(res2.responseText);
// Call appropriate feature processor
if (gisLayer.platform === 'ArcGIS' || !gisLayer.platform) {
processFeaturesArcGIS(parsedData, res2.context, gisLayer);
} else if (isSocrata) {
processFeaturesGeoJSON(parsedData, res2.context, gisLayer);
} else {
logError(`Unknown platform "${gisLayer.platform}" for layer "${gisLayer.id}". Skipped processing.`);
}
} catch (parseError) {
logError(`Parsing error for layer "${gisLayer.id}": ${parseError.message}`);
$(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
}
layersProcessedCount += 1;
if (layersProcessedCount === layersToFetch.length && isPopupVisible) {
updatePopup(layerLabels);
}
} else {
logError(`HTTP error for layer "${gisLayer.id}": ${res2.status} ${res2.statusText}`);
$(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
}
},
onerror(res3) {
logError(`Could not fetch layer "${gisLayer.id}". Error: ${res3.statusText} (status code: ${res3.status})`);
$(`#gis-layer-${gisLayer.id}-container > label`).css('color', 'red');
},
});
});
}
function showScriptInfoAlert() {
/* Check version and alert on update */
if (SHOW_UPDATE_MESSAGE && scriptVersion !== settings.lastVersion) {
// alert(SCRIPT_VERSION_CHANGES);
let releaseNotes = '';
releaseNotes += "<p>What's New:</p>";
if (SCRIPT_VERSION_CHANGES.length > 0) {
releaseNotes += '<ul>';
for (let idx = 0; idx < SCRIPT_VERSION_CHANGES.length; idx++) releaseNotes += `<li>${SCRIPT_VERSION_CHANGES[idx]}`;
releaseNotes += '</ul>';
} else {
releaseNotes += '<ul><li>Nothing major.</ul>';
}
WazeWrap.Interface.ShowScriptUpdate(GM_info.script.name, scriptVersion, releaseNotes, GF_URL);
}
}
async function setEnabled(value) {
settings.enabled = value;
saveSettingsToStorage();
sdk.Map.setLayerVisibility({ layerName: DEFAULT_LAYER_NAME, visibility: value });
sdk.Map.setLayerVisibility({ layerName: ROAD_LAYER_NAME, visibility: value });
const color = value ? '#00bd00' : '#ccc';
$('span#gis-layers-power-btn').css({ color });
if (value) await fetchFeatures();
sdk.LayerSwitcher.setLayerCheckboxChecked({ name: 'GIS Layers', isChecked: value });
// Show/hide the popup based on the enabled state
const popup = document.getElementById('layerLabelPopup');
if (popup) {
popup.style.display = value ? 'block' : 'none';
isPopupVisible = value;
}
}
async function onGisLayerToggleChanged() {
const checked = $(this).is(':checked');
const layerId = $(this).data('layer-id');
const idx = settings.visibleLayers.indexOf(layerId);
if (checked) {
const gisLayer = _gisLayers.find((l) => l.id === layerId);
if (gisLayer.oneTimeAlert) {
const lastAlertHash = settings.oneTimeAlerts[layerId];
const newAlertHash = hashString(gisLayer.oneTimeAlert);
if (lastAlertHash !== newAlertHash) {
// alert(`Layer: ${gisLayer.name}\n\nMessage:\n${gisLayer.oneTimeAlert}`);
WazeWrap.Alerts.info(GM_info.script.name, `Layer: ${gisLayer.name}<br><br>Message:<br>${gisLayer.oneTimeAlert}`);
settings.oneTimeAlerts[layerId] = newAlertHash;
saveSettingsToStorage();
}
}
if (idx === -1) settings.visibleLayers.push(layerId);
} else if (idx > -1) settings.visibleLayers.splice(idx, 1);
if (!ignoreFetch) {
saveSettingsToStorage();
await fetchFeatures();
}
}
async function onOnlyShowApplicableLayersChanged() {
settings.onlyShowApplicableLayers = $(this).is(':checked');
saveSettingsToStorage();
filterLayerCheckboxes();
}
async function onOnlyShowApplicableLayersZoomChanged() {
settings.onlyShowApplicableLayersZoom = $(this).is(':checked');
saveSettingsToStorage();
filterLayerCheckboxes();
}
async function onSub1CheckChanged(subL1, evt) {
const idx = settings.selectedSubL1.indexOf(subL1);
if (evt.target.checked) {
if (idx === -1) settings.selectedSubL1.push(subL1);
} else if (idx > -1) settings.selectedSubL1.splice(idx, 1);
if (!ignoreFetch) {
saveSettingsToStorage();
initLayersTab();
await fetchFeatures();
}
}
async function batchUpdateSelectedSubL1() {
// Gather all checked subL1's from DOM
const checked = $('.gis-layers-subL1-checkbox:checked')
.map(function () {
return $(this).data('sub');
})
.get();
settings.selectedSubL1 = checked;
if (!ignoreFetch) {
saveSettingsToStorage();
initLayersTab();
await fetchFeatures();
}
}
function onLayerCheckboxChanged(args) {
setEnabled(args.checked);
}
function setFillParcels(doFill) {
[LAYER_STYLES.parcels, LAYER_STYLES.state_parcels].forEach((style) => {
style.fillOpacity = doFill ? 0.2 : 0;
});
}
async function onFillParcelsCheckedChanged(evt) {
const { checked } = evt.target;
setFillParcels(checked);
settings.fillParcels = checked;
saveSettingsToStorage();
await fetchFeatures();
}
async function onMapMove() {
if (settings.enabled) {
await loadVisibleCountryData();
await fetchFeatures();
}
}
function onRefreshLayersClick() {
const $btn = $('#gis-layers-refresh');
if (!$btn.hasClass('fa-spin')) {
$btn.css({ cursor: 'auto' });
$btn.addClass('fa-spin');
init(false);
}
}
function onChevronClick(evt) {
const $target = $(evt.currentTarget);
const $div = $($target.siblings()[0]);
const fieldsetId = $target.parent('fieldset').attr('id');
const sectionKey = fieldsetId ? fieldsetId.replace('gis-layers-for-', '') : null;
$($target.children()[0]).toggleClass('fa fa-fw fa-chevron-down').toggleClass('fa fa-fw fa-chevron-right');
if ($div.css('display') === 'none') {
$div.css('display', 'block');
if (sectionKey) settings.collapsedSections[sectionKey] = false;
} else {
$div.css('display', 'none');
if (sectionKey) settings.collapsedSections[sectionKey] = true;
}
if (sectionKey) saveSettingsToStorage();
}
async function doToggleABunch(evt, checkState) {
ignoreFetch = true;
$(evt.target).closest('fieldset').find('input').prop('checked', !checkState).trigger('click');
ignoreFetch = false;
saveSettingsToStorage();
if (evt.data) initLayersTab();
await fetchFeatures();
}
function onSelectAllClick(evt) {
doToggleABunch(evt, true);
}
function onSelectNoneClick(evt) {
doToggleABunch(evt, false);
}
async function onGisAddrDisplayChange(evt) {
settings.addrLabelDisplay = evt.target.value;
saveSettingsToStorage();
await fetchFeatures();
}
function onAddressDisplayShortcutKey() {
if (!$('#gisAddrDisplay-hn').is(':checked')) {
$('#gisAddrDisplay-hn').click();
} else {
$('#gisAddrDisplay-all').click();
}
}
function onToggleGisLayersShortcutKey() {
setEnabled(!settings.enabled);
}
function togglePopupVisibility() {
const popup = document.getElementById('layerLabelPopup');
if (popup) {
popup.style.display = isPopupVisible ? 'block' : 'none';
}
saveSettingsToStorage();
}
/**
* Initializes and configures GIS map layers on the map SDK.
*
* This function:
* - Generates style rules for each GIS layer (excluding those with 'roads' style),
* - Sets parcel fill visualization based on settings,
* - Removes then adds map layers with appropriate styling and label contexts,
* - Sets layer visibility according to current settings.
*
* Dependencies and required globals:
* - _gisLayers: Array of GIS layer objects ({ id, style, ... })
* - LAYER_STYLES: Object containing available layer styles
* - settings: Layer-related user/application settings ({ fillParcels, enabled })
* - sdk: WME SDK object
* - DEFAULT_LAYER_NAME, ROAD_LAYER_NAME: String constants for layer names
* - DEFAULT_STYLE, ROAD_STYLE: Style objects for layers
* - setFillParcels: Function to update parcel visualization
*
* Side Effects:
* - Modifies visible layers on the map via sdk.Map
* - May throw or suppress errors depending on layer state
*
* @function initLayer
*/
function initLayer() {
const rules = _gisLayers
.filter((gisLayer) => gisLayer.style && gisLayer.style !== 'roads')
.map((gisLayer) => {
let style;
if (LAYER_STYLES.hasOwnProperty(gisLayer.style)) {
style = LAYER_STYLES[gisLayer.style];
} else {
style = gisLayer.style;
}
return {
predicate: (featureProperties) => featureProperties.layerID === gisLayer.id,
style,
};
});
setFillParcels(settings.fillParcels);
try {
sdk.Map.removeLayer({ layerName: DEFAULT_LAYER_NAME });
} catch (e) {
// If InvalidStateError, then the layer doesn't exist yet. Ignore the error
if (!(e instanceof sdk.Errors.InvalidStateError)) {
throw e;
}
}
sdk.Map.addLayer({
layerName: DEFAULT_LAYER_NAME,
styleContext: {
getLabel: (context) => context.feature?.properties?.label,
},
styleRules: [{ style: DEFAULT_STYLE }, ...rules],
zIndexing: true,
});
try {
sdk.Map.removeLayer({ layerName: ROAD_LAYER_NAME });
} catch (e) {
// If InvalidStateError, then the layer doesn't exist yet. Ignore the error
if (!(e instanceof sdk.Errors.InvalidStateError)) {
throw e;
}
}
const zoomLevel = sdk.Map.getZoomLevel();
sdk.Map.addLayer({
layerName: ROAD_LAYER_NAME,
styleContext: {
getLabel: (context) => context.feature?.properties?.label,
getOffset: () => -(zoomLevel + 5),
getSmooth: () => '',
getReadable: () => '1',
},
styleRules: [{ style: ROAD_STYLE }],
});
sdk.Map.setLayerVisibility({ layerName: DEFAULT_LAYER_NAME, visibility: settings?.enabled });
sdk.Map.setLayerVisibility({ layerName: ROAD_LAYER_NAME, visibility: settings?.enabled });
} // END InitLayer
/**
* Initializes and renders the GIS Layers tab user interface.
*
* This function rebuilds the '#panel-gis-subL1-layers' container DOM,
* including checkboxes and controls for filtering layers by region, zoom level,
* and specific SubL1 categories. It binds all relevant event handlers for interactions.
*
* Dependencies (must be in scope when called):
* - userInfo: { userName }
* - settings: contains selectedSubL1, onlyShowApplicableLayers, onlyShowApplicableLayersZoom, visibleLayers, collapsedSections
* - _gisLayers: List of GIS layer objects, each with { id, name, countrySubL1, restrictTo }
* - NameMapper: object with method toFullName(subL1) -> string
* - jQuery ($)
* - Lodash (_)
* - Event handlers: onOnlyShowApplicableLayersChanged, onOnlyShowApplicableLayersZoomChanged, onSelectAllClick, onSelectNoneClick, onChevronClick, onGisLayerToggleChanged
*
* Side Effects:
* - Modifies the DOM inside #panel-gis-subL1-layers
* - Sets up interactive controls for GIS layer filtering and visibility
*
* @function
*/
function initLayersTab() {
const subL1 = _.uniq(_gisLayers.map((l) => l.countrySubL1)).filter((sub) => settings?.selectedSubL1?.includes(sub));
$('#panel-gis-subL1-layers')
.empty()
.append(
$('<div>', { class: 'controls-container' })
.css({ 'padding-top': '0px' })
.append(
$('<input>', { type: 'checkbox', id: 'only-show-applicable-gis-layers' }).on('change', onOnlyShowApplicableLayersChanged).prop('checked', settings?.onlyShowApplicableLayers),
$('<label>', { for: 'only-show-applicable-gis-layers' }).css({ 'white-space': 'pre-line' }).text('Only show applicable layers for Region')
),
$('<div>', { class: 'controls-container' })
.css({ 'padding-top': '0px' })
.append(
$('<input>', { type: 'checkbox', id: 'only-show-applicable-gis-layers-for-zoom-level' })
.on('change', onOnlyShowApplicableLayersZoomChanged)
.prop('checked', settings?.onlyShowApplicableLayersZoom),
$('<label>', { for: 'only-show-applicable-gis-layers-for-zoom-level' }).css({ 'white-space': 'pre-line' }).text('Include Zoom Level in filter')
),
$('.gis-layers-subL1-checkbox:checked').length === 0
? $('<div>').text('Turn on layer categories in the Settings tab.')
: subL1.map((sub) =>
$('<fieldset>', {
id: `gis-layers-for-${sub}`,
style: 'border:1px solid silver;padding:4px;border-radius:4px;-webkit-padding-before: 0;',
}).append(
$('<legend>', { style: 'margin-bottom:0px;border-bottom-style:none;width:auto;' })
.on('click', onChevronClick)
.append(
$('<i>', {
class: settings?.collapsedSections[sub] ? 'fa fa-fw fa-chevron-right' : 'fa fa-fw fa-chevron-down',
style: 'cursor: pointer;font-size: 12px;margin-right: 4px',
}),
$('<span>', {
style: 'font-size:14px;font-weight:600;text-transform: uppercase; cursor: pointer',
}).text(NameMapper.toFullName(sub))
),
$('<div>', {
id: `${sub}_body`,
style: settings?.collapsedSections[sub] ? 'display: none;' : 'display: block;',
}).append(
$('<div>')
.css({ 'font-size': '11px' })
.append(
$('<span>').append('Select ', $('<a>', { href: '#' }).text('All').on('click', onSelectAllClick), ' / ', $('<a>', { href: '#' }).text('None').on('click', onSelectNoneClick))
),
$('<div>', { class: 'controls-container', style: 'padding-top:0px;' }).append(
_gisLayers
.filter((l) => l.countrySubL1 === sub)
.map((gisLayer) => {
const id = `gis-layer-${gisLayer.id}`;
return $('<div>', { class: 'controls-container', id: `${id}-container` })
.css({ 'padding-top': '0px', display: 'block' })
.append(
$('<input>', { type: 'checkbox', id }).data('layer-id', gisLayer.id).on('change', onGisLayerToggleChanged).prop('checked', settings?.visibleLayers?.includes(gisLayer.id)),
$('<label>', { for: id, class: 'gis-subL1-layer-label' })
.css({ 'white-space': 'pre-line' })
.text(`${gisLayer.name}${gisLayer.restrictTo ? ' *' : ''}`)
.attr('title', gisLayer.restrictTo ? `Restricted to: ${gisLayer.restrictTo}` : '')
.on('contextmenu', (evt) => {
evt.preventDefault();
_layerSettingsDialog.gisLayer = gisLayer;
_layerSettingsDialog.show();
})
);
})
)
)
)
)
);
}
/**
* Initializes and renders the GIS Layers "Settings" tab UI.
*
* This function dynamically builds the user interface for the GIS settings panel,
* allowing users to control label display, popup options, country/group enablement,
* layer appearance (e.g., fill parcels), and manage special tokens for data access.
*
* Features:
* - Group GIS layers by country and present checkboxes for subregion enablement.
* - Provide radio buttons for address label and popup display settings.
* - Provide 'Select All' / 'Select None' batch controls for subregions per country.
* - Present appearance options (e.g., "Fill parcels" toggle).
* - Manage Tyler/Socrata App Token with in-panel input and help links.
* - Integrate custom group management and "Load All Layers" functionality.
* - Set up all necessary event handlers for user interactions (clicks/change, etc.).
*
* Dependencies (must be defined in scope at runtime):
* - _gisLayers: Array of GIS layer objects ({id, name, country, countrySubL1, ...})
* - settings: Object containing UI/user state/settings (see code for properties used)
* - NameMapper: Object/function mapping region codes to display names (`toFullName`)
* - SCRIPT_AUTHOR: String for author/contact (for tooltips)
* - jQuery ($), Lodash (_)
* - Event/callback handlers: onChevronClick, onSub1CheckChanged, onFillParcelsCheckedChanged, onGisAddrDisplayChange, openLayerGroupManagerDialog, batchUpdateSelectedSubL1, saveSettingsToStorage, loadSpreadsheetAsync, initTab, logDebug, logError, togglePopupVisibility
* - isPopupVisible: Boolean flag for popup state (mutated)
*
* Side Effects:
* - Rebuilds the DOM within #panel-gis-layers-settings
* - Registers event handlers and toggles settings state objects
* - May trigger async functions for loading layers/groups and updating settings
*
* @function initSettingsTab
* @returns {void}
*/
function initSettingsTab() {
// Group layers by country
const layersByCountry = _.groupBy(_gisLayers, 'country');
/**
* Creates a radio input and label as jQuery objects.
* @param {string} name
* @param {string} value
* @param {string} text
* @param {boolean} checked
* @returns {Array} [input, label] as jQuery objects
*/
function createRadioBtn(name, value, text, checked) {
const id = `${name}-${value}`;
return [
$('<input>', {
type: 'radio',
id,
name,
value,
}).prop('checked', checked),
$('<label>', { for: id }).text(text).css({
paddingLeft: '15px',
marginRight: '4px',
}),
];
}
$('#panel-gis-layers-settings')
.empty()
.append(
$('<fieldset>', {
style: 'border:1px solid silver;padding:8px;border-radius:4px;-webkit-padding-before: 0;margin-top:-8px;',
}).append(
$('<legend>', {
style: 'margin-bottom:0px;border-bottom-style:none;width:auto;',
}).append(
$('<span>', {
style: 'font-size:14px;font-weight:600;text-transform: uppercase;',
}).text('Labels')
),
$('<div>', { id: 'labelSettings' }).append(
$('<div>', { class: 'controls-container' })
.css({ 'padding-top': '2px' })
.append(
$('<label>', { style: 'font-weight:normal;' }).text('Addresses:'),
createRadioBtn('gisAddrDisplay', 'hn', 'HN', settings.addrLabelDisplay === 'hn'),
createRadioBtn('gisAddrDisplay', 'street', 'Street', settings.addrLabelDisplay === 'street'),
createRadioBtn('gisAddrDisplay', 'all', 'Both', settings.addrLabelDisplay === 'all'),
createRadioBtn('gisAddrDisplay', 'none', 'None', settings.addrLabelDisplay === 'none'),
// You may get TS errors for tooltip() unless you declare it (see previous answer)
$('<i>', {
class: 'waze-tooltip',
id: 'gisAddrDisplayInfo',
'data-toggle': 'tooltip',
style: 'margin-left:8px; font-size:12px',
'data-placement': 'bottom',
title: `This may not work properly for all layers. Please report issues to ${SCRIPT_AUTHOR}.`,
}).tooltip(),
$('<br>'),
$('<label>', { style: 'font-weight:normal; margin-left:8px;' }).text('Label Popup:'),
createRadioBtn('popupVisibility', 'show', 'Show', isPopupVisible),
createRadioBtn('popupVisibility', 'hide', 'Hide', !isPopupVisible)
)
)
)
);
// Create groups by country
Object.keys(layersByCountry)
.sort()
.forEach((country) => {
const subRegions = _.uniq(layersByCountry[country].map((l) => l.countrySubL1));
// Unique selector base for this country
const countryContainerId = `country_${country}_body`;
$('#panel-gis-layers-settings').append(
$('<fieldset>', { style: 'border:1px solid silver;padding:8px;border-radius:4px;-webkit-padding-before:0;' }).append(
$('<legend>', { style: 'margin-bottom:0px;border-bottom-style:none;width:auto;' })
// OLD: .click(onChevronClick) -- DEPRECATED
.on('click', onChevronClick)
.append(
$('<i>', { class: 'fa fa-fw fa-chevron-down', style: 'cursor: pointer;font-size: 12px;margin-right: 4px' }),
$('<span>', { style: 'font-size:14px;font-weight:600;text-transform:uppercase;' }).text(NameMapper.toFullName(country))
),
$('<div>', { id: countryContainerId }).append(
// One Select All/None row per COUNTRY
$('<div>', { class: 'gis-select-all-controls', style: 'font-size:11px;margin-bottom:4px;' }).append(
'Select ',
$('<a>', { href: '#', 'data-country': country, class: 'gis-select-all-country' }).text('All'),
' / ',
$('<a>', { href: '#', 'data-country': country, class: 'gis-select-none-country' }).text('None')
),
// All the subregion checkboxes
subRegions.map((countrySubL1) => {
const fullName = NameMapper.toFullName(countrySubL1);
const id = `gis-layer-enable-subL1-${countrySubL1}`;
return $('<div>', { class: 'controls-container' })
.css({ 'padding-top': '0px', display: 'block' })
.append(
$('<input>', {
type: 'checkbox',
id,
class: 'gis-layers-subL1-checkbox',
'data-sub': countrySubL1,
'data-country': country,
})
.on('change', (evt) => onSub1CheckChanged(countrySubL1, evt)) // <--- pass subL1
.prop('checked', settings.selectedSubL1.includes(countrySubL1)),
$('<label>', { for: id }).css({ 'white-space': 'pre-line' }).text(fullName)
);
})
)
)
);
});
$('#panel-gis-layers-settings').append(
$('<fieldset>', { style: 'border:1px solid silver;padding:8px;border-radius:4px;-webkit-padding-before:0;' }).append(
$('<legend>', { style: 'margin-bottom:0px;border-bottom-style:none;width:auto;' }).append(
$('<span>', { style: 'font-size:14px;font-weight:600;text-transform:uppercase;' }).text('Appearance')
),
$('<div>', { class: 'controls-container' })
.css({ 'padding-top': '2px' })
.append(
$('<input>', { type: 'checkbox', id: 'fill-parcels' }).change(onFillParcelsCheckedChanged).prop('checked', settings.fillParcels),
$('<label>', { for: 'fill-parcels' }).css({ 'white-space': 'pre-line' }).text('Fill parcels')
)
)
);
// ---- SOCRATA APP TOKEN SECTION ----
$('#panel-gis-layers-settings').append('<div id="socrata-app-token-anchor"></div>');
function renderSocrataAppTokenSection() {
$('#socrata-app-token-section').remove();
const hasToken = !!settings.socrataAppToken;
const inputType = hasToken ? 'password' : 'text';
const inputVal = hasToken ? settings.socrataAppToken : '';
const inputPh = hasToken ? 'Token is set' : 'Enter Socrata App Token';
const btnLabel = hasToken ? 'Remove' : 'Save';
const $fieldset = $('<fieldset>', {
id: 'socrata-app-token-section',
style: 'border:1px solid #b9b9b9;margin-top:6px;padding:8px;border-radius:4px;',
}).append(
$('<legend>', {
style: 'margin-bottom:0px;border-bottom-style:none;width:auto;',
}).append(
$('<span>', {
style: 'font-size:14px;font-weight:600;text-transform:uppercase;',
}).text('Tyler/Socrata App Token')
),
$('<div>', {
style: ['display:flex', 'gap:8px', 'align-items:center', 'border:1px solid #b9b9b9', 'border-radius:4px', 'padding:4px 8px'].join(';'),
}).append(
$('<input>', {
type: inputType,
id: 'socrata-app-token-input',
style: ['flex:1 1 auto', 'border:none', 'background:transparent', 'outline:none', 'font-size:12px', 'padding:4px 0', 'color:inherit'].join(';'),
placeholder: inputPh,
disabled: hasToken, // disable input when token is set
}).val(inputVal),
$('<button>', {
id: 'save-socrata-app-token-btn',
style: ['border:none', 'background:transparent', 'color:#335', 'margin:0 2px', 'padding:2px 10px', 'border-radius:3px', 'font-size:13px', 'cursor:pointer'].join(';'),
text: btnLabel,
})
),
$('<div>', {
style: 'margin:6px 2px 0 2px;',
}).append(
$('<span>', {
style: 'color:#777;font-size:11px;',
html: 'Recommended for all <b>·</b> <span style="color:#b00;">Required for V3 API</span>',
})
)
);
if (!hasToken) {
// Show help links if token is not set
const $helpDiv = $('<div>', {
style: 'margin:2px 2px 0 2px;font-size:11px;',
}).append(
$('<div>').append(
$('<a>', {
href: 'https://support.socrata.com/hc/en-us/articles/115004055807-How-to-Sign-Up-for-a-Tyler-Data-Insights-ID',
target: '_blank',
rel: 'noopener noreferrer',
style: 'color:#357ab8;text-decoration:underline;',
text: 'How to Sign Up for a Tyler Data & Insights ID',
})
),
$('<div>').append(
$('<a>', {
href: 'https://support.socrata.com/hc/en-us/articles/210138558-Generating-App-Tokens-and-API-Keys',
target: '_blank',
rel: 'noopener noreferrer',
style: 'color:#357ab8;text-decoration:underline;',
text: 'How to Generating App Tokens',
})
)
);
$fieldset.append($helpDiv);
}
// (insert after anchor)
$('#socrata-app-token-anchor').after($fieldset);
// Single handler for the button
$('#save-socrata-app-token-btn')
.off('click')
.on('click', function () {
if (!hasToken) {
const token = String($('#socrata-app-token-input').val()).trim();
settings.socrataAppToken = token;
saveSettingsToStorage();
$(this)
.text('Saved!')
.delay(1000)
.queue(function (next) {
$(this).text('Remove');
next();
});
} else {
// Remove the token
settings.socrataAppToken = '';
saveSettingsToStorage();
}
renderSocrataAppTokenSection();
});
}
renderSocrataAppTokenSection();
// ---- SOCRATA APP TOKEN SECTION END
$('input[name="gisAddrDisplay"]').on('change', onGisAddrDisplayChange);
$('input[name="popupVisibility"]').on('change', function () {
isPopupVisible = $(this).val() === 'show';
togglePopupVisibility();
});
// -- CUSTOM Group Popup & Load All Button --
$('#panel-gis-layers-settings').append(
$('<fieldset>', { style: 'border:1px solid #8ea0b7;margin-top:6px;padding:8px;border-radius:4px;' }).append(
$('<legend>', { style: 'margin-bottom:0px;border-bottom-style:none;width:auto;' }).append(
$('<span>', { style: 'font-size:14px;font-weight:600;text-transform:uppercase;' }).text('Layer Groupings')
),
$('<div>').append(
$('<button>', {
id: 'gis-manager-launch-btn',
class: 'form-control',
style: 'display:inline-block;padding:2px 8px;margin-top:3px; background:#4d6a88; color:#eaf6ff; border:1px solid #50667b;',
}).text('Manage Custom Groups'),
$('<button>', {
id: 'gis-load-all-btn',
class: 'form-control',
style: 'display:inline-block;padding:2px 8px;margin-top:3px;background:#548342;color:#fff;border:1px solid #406927;',
title: 'Load ALL country/state/region layers for custom grouping (slower)',
}).text('Load All Layers')
)
)
);
$('#gis-manager-launch-btn').off('click').on('click', openLayerGroupManagerDialog);
$('#gis-load-all-btn')
.off('click')
.on('click', async function () {
$(this).prop('disabled', true).text('Loading...');
try {
await loadSpreadsheetAsync('ALL', 'ALL');
initTab(false);
logDebug('All layers loaded!');
} catch (e) {
logError(`Error in load all Layers: ${e.message || e}`);
}
$(this).prop('disabled', false).text('Load All Layers');
});
// -- END CUSTOM Group Popup & Load All Button --
// Select all subregions under a country functionality
$('#panel-gis-layers-settings')
.off('click', '.gis-select-all-country')
.on('click', '.gis-select-all-country', async function (e) {
e.preventDefault();
const country = $(this).data('country');
// Check all
$(`.gis-layers-subL1-checkbox[data-country="${country}"]`).prop('checked', true);
await batchUpdateSelectedSubL1(); // <- collect and process only ONCE!
});
$('#panel-gis-layers-settings')
.off('click', '.gis-select-none-country')
.on('click', '.gis-select-none-country', async function (e) {
e.preventDefault();
const country = $(this).data('country');
// Uncheck all
$(`.gis-layers-subL1-checkbox[data-country="${country}"]`).prop('checked', false);
await batchUpdateSelectedSubL1(); // <- collect and process only ONCE!
});
}
/**
* Initializes the GIS Layers tab UI.
*
* - On the first call, generates tab content dynamically and registers the tab with the sidebar.
* - Sets various UI elements: labels, buttons, a report request link, refresh icon, and settings panel.
* - Wires up event handlers for toggling GIS Layers and refreshing layer info.
* - Always calls sub-initializers for settings and layers.
*
* @async
* @function initTab
* @param {boolean} [firstCall=true] - Whether this is the first initialization (controls tab registration and content rendering).
* @returns {Promise<void>} Resolves when initialization is complete.
*/
async function initTab(firstCall = true) {
if (firstCall) {
// Build the tab content UI, including version, report/request link, refresh button, and tab panes.
const content = $('<div>')
.append(
// Script name and version.
$('<span>', { style: 'font-size:14px;font-weight:600' }).text('GIS Layers'),
$('<span>', { style: 'font-size:11px;margin-left:10px;color:#aaa;' }).text(GM_info.script.version),
// Report/request Google Form link.
$('<a>', {
href: REQUEST_FORM_URL.replace('{username}', userInfo?.userName ?? ''),
target: '_blank',
style: 'color: #6290b7;font-size: 12px;margin-left: 8px;',
title: 'Report broken layers, bugs, request new layers, script features',
}).text('Submit a request'),
// Refresh icon.
$('<span>', {
id: 'gis-layers-refresh',
class: 'fa fa-refresh',
style: 'float: right;',
'data-toggle': 'tooltip',
title: 'Pull new layer info from master sheet and refresh all layers.',
}),
// Nav tabs for layer/settings panels.
'<ul class="nav nav-tabs">' +
'<li class="active"><a data-toggle="tab" href="#panel-gis-subL1-layers" aria-expanded="true">Layers</a></li>' +
'<li><a data-toggle="tab" href="#panel-gis-layers-settings" aria-expanded="true">Settings</a></li>' +
'</ul>',
// Tab panels for layers and settings.
$('<div>', { class: 'tab-content', style: 'padding:8px;padding-top:2px' }).append(
$('<div>', { class: 'tab-pane active', id: 'panel-gis-subL1-layers', style: 'padding: 4px 0px 0px 0px; width: auto' }),
$('<div>', { class: 'tab-pane', id: 'panel-gis-layers-settings', style: 'padding: 4px 0px 0px 0px; width: auto' })
)
)
.html();
// Build the "power" button and label.
const powerButtonColor = settings.enabled ? '#00bd00' : '#ccc';
const labelText = $('<div>')
.append(
$('<span>', {
class: 'fa fa-power-off',
id: 'gis-layers-power-btn',
style: `margin-right: 5px;cursor: pointer;color: ${powerButtonColor};font-size: 13px;`,
title: 'Toggle GIS Layers',
}),
$('<span>', { title: 'GIS Layers' }).text('GIS-L')
)
.html();
// Register a new script tab in the sidebar and fill in content.
const { tabLabel, tabPane } = await sdk.Sidebar.registerScriptTab();
tabLabel.innerHTML = labelText;
tabPane.innerHTML = content;
// Tweak tab spacing and wire up power and refresh buttons.
$(tabPane).parent().css({ width: 'auto', padding: '6px' });
$('#gis-layers-power-btn').on('click', function () {
setEnabled(!settings.enabled);
// Prevent parent tab activation when toggling GIS-Layers.
return false;
});
$('#gis-layers-refresh').on('click', onRefreshLayersClick);
}
// Always initialize settings and layer panels.
initSettingsTab();
initLayersTab();
}
/**
* Initializes the GIS Layers script UI and event handlers, including tab content, layer controls, and listeners.
*
* - On first initialization, sets up the layer tab, adds the GIS Layers checkbox to the Layer Switcher,
* synchronizes its checked state with settings, subscribes to layer and map events, and displays the script info alert.
* - On subsequent calls, reinitializes the tab contents with the current state.
*
* @function initGui
* @param {boolean} [firstCall=true] - Whether this is the initial setup or a subsequent refresh.
* @returns {void}
*/
function initGui(firstCall = true) {
initLayer();
if (firstCall) {
initTab(true);
sdk.LayerSwitcher.addLayerCheckbox({ name: 'GIS Layers' });
sdk.LayerSwitcher.setLayerCheckboxChecked({ name: 'GIS Layers', isChecked: settings.enabled });
sdk.Events.on({ eventName: 'wme-layer-checkbox-toggled', eventHandler: onLayerCheckboxChanged });
sdk.Events.on({ eventName: 'wme-map-move-end', eventHandler: onMapMove });
showScriptInfoAlert();
} else {
initTab(firstCall);
}
}
/**
* Opens the GIS Layer Group Manager dialog for managing saved layer/region groups.
*
* - Renders a draggable dialog unless already open.
* - Allows the user to save, load, and delete "layer groups": sets of currently selected regions and visible GIS layers.
* - Integrates with `settings` (for state), WazeWrap.Alerts (for confirmation/prompt), and uses jQuery for UI.
* - Cleans up event handlers on close/escape.
*
* @function openLayerGroupManagerDialog
* @returns {void}
*/
function openLayerGroupManagerDialog() {
if ($('#gis-layer-group-dialog').length) return;
// --- Color & style constants for easy palette harmonization ---
const BTN_STYLE_BLUE =
'min-width:120px;height:38px;display:flex;align-items:center;justify-content:center;' +
'border:1.5px solid #50667b;border-radius:7px; font-size:15px;font-weight:600;' +
'background:#4d6a88;color:#eaf6ff;box-shadow:0 2px 7px #35587015;cursor:pointer; outline:none;';
const BTN_STYLE_GREEN =
'min-width:120px;height:38px;display:flex;align-items:center;justify-content:center;' +
'border:1.5px solid #406927;border-radius:7px;font-size:15px;font-weight:600;' +
'background:#548342;color:#fff;box-shadow:0 2px 7px #35587015;cursor:pointer; outline:none;';
const BTN_STYLE_RED =
'min-width:120px;height:38px;display:flex;align-items:center;justify-content:center;' +
'border:1.5px solid #9b2020;border-radius:7px;font-size:15px;font-weight:600;' +
'background:#c14444;color:#fff;box-shadow:0 2px 7px #35587015;cursor:pointer; outline:none;';
const BTN_STYLE_ORANGE =
'min-width:120px;height:38px;display:flex;align-items:center;justify-content:center;' +
'border:1.5px solid #9c5b13;border-radius:7px;font-size:15px;font-weight:600;' +
'background:#d58431;color:#fff;box-shadow:0 2px 7px #35587015;cursor:pointer; outline:none;';
const scriptName = typeof GM_info !== 'undefined' ? GM_info.script.name : 'Layer Groups';
// Header and close
const $title = $('<span>').text(scriptName + ' — Layer Groups');
const $close = $('<span>', {
style: 'cursor:pointer;padding-left:14px;font-size:20px;color:#eaf6ff;float:right;',
class: 'fa fa-window-close',
title: 'Close',
tabindex: 0,
}).on('click keydown', function (e) {
if (e.type === 'click' || (e.type === 'keydown' && (e.key === 'Enter' || e.key === ' '))) $dlg.remove();
});
// Dialog container
const $dlg = $('<div>', {
id: 'gis-layer-group-dialog',
style:
'position:fixed; top:14%; left:420px; width:400px; z-index:99999;' +
'background:#73a9bd; border-width:1px; border-style:solid; border-radius:14px;' +
'box-shadow:5px 6px 14px rgba(0,0,0,0.58); border-color:#50667b; padding:0; font-family:inherit;',
});
// Header
$dlg.append(
$('<div>', {
style: 'border-radius:14px 14px 0px 0px; padding: 7px 14px; color: #fff; background:#4d6a88; font-weight:bold; text-align:left; font-size:17px;',
}).append($title, $close)
);
// --- Section: Current Selection ---
const $section1 = $('<div>', {
style: 'border-radius: 7px; background: #d6e6f3; margin:8px 8px 8px 8px; padding:8px 8px 8px 8px; box-shadow:0 1px 5px #0001;',
}).append(
$('<div>', { style: 'font-size:15.5px;font-weight:700;color:#355870;margin-bottom:6px;' }).text('Current Selection'),
$('<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.'),
$('<div>', { style: 'display:flex;gap:14px;align-items:center;margin-top:4px;' }).append(
$('<button>', {
class: 'GISGroupDlg-btn',
style: BTN_STYLE_RED,
title: 'Remove all selected sub-regions and visible layers',
})
.text('Clear All')
.on('click', function () {
WazeWrap.Alerts.confirm(
scriptName,
'<div style="color:#ff0000; font-size:17px; font-weight:bold; padding:10px 0; text-align:center;">' +
'Are you sure you want to remove all visible layers, and region selections?' +
'</div>',
function () {
settings.selectedSubL1 = [];
settings.visibleLayers = [];
settings.collapsedSections = {};
saveSettingsToStorage();
loadSettingsFromStorage();
initGui(false);
$dlg.remove();
}
);
}),
$('<button>', {
class: 'GISGroupDlg-btn',
style: BTN_STYLE_BLUE,
title: 'Save current layers and selections as a group',
})
.text('Save as Group')
.on('click', function () {
WazeWrap.Alerts.prompt(scriptName, 'Enter a name for this group:', '', function (result, name) {
if (!result || !name || !name.trim()) return;
settings.layerGroups = settings.layerGroups || {};
if (settings.layerGroups[name]) {
WazeWrap.Alerts.confirm(scriptName, 'Group "' + name + '" exists. Overwrite?', function () {
doSaveGroup(name, true);
});
} else {
doSaveGroup(name, false);
}
/**
* @param {string} groupName - Name for the saved group.
* @param {boolean} overwritten - If true, notify user it's an overwrite.
* @returns {void}
*/
function doSaveGroup(groupName, overwritten) {
settings.layerGroups[groupName] = {
selectedSubL1: [...settings.selectedSubL1],
visibleLayers: [...settings.visibleLayers],
collapsedSections: { ...settings.collapsedSections },
addrLabelDisplay: settings.addrLabelDisplay,
fillParcels: settings.fillParcels,
};
saveSettingsToStorage();
loadSettingsFromStorage();
populateGroupSelect();
setTimeout(function () {
if (typeof WazeWrap !== 'undefined' && WazeWrap.Alerts && typeof WazeWrap.Alerts.success === 'function') {
WazeWrap.Alerts.success(scriptName, 'Layer group saved as "' + groupName + '"' + (overwritten ? ' (overwritten)' : ''));
} else {
alert('Layer group saved as "' + groupName + '"' + (overwritten ? ' (overwritten)' : ''));
}
}, 150);
}
});
})
)
);
// --- Section: My Saved Groups ---
const $groupSelect = $('<select>', {
id: 'gis-layer-group-select',
style:
'font-size:13px; border-radius:4px; border:1px solid #356079; padding:7px 12px;' +
'min-width:250px; max-width:365px; margin-right:8px; outline:none;' +
'background:#eaf4fd; color:#17354e; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;',
});
/**
* Populates the group selection drop-down with saved layer groups from settings.
* If no groups exist, shows a disabled "No groups saved" message.
*
* @function populateGroupSelect
* @returns {void}
*/
function populateGroupSelect() {
$groupSelect.empty();
const groups = settings.layerGroups || {};
if (Object.keys(groups).length === 0) {
$groupSelect.append($('<option>', { disabled: true, selected: true, text: 'No groups saved' }));
return;
}
$groupSelect.append($('<option>', { selected: true, disabled: true, text: 'Select group...' }));
Object.keys(groups).forEach((groupName) => {
$groupSelect.append($('<option>', { value: groupName, text: groupName, title: groupName }));
});
}
populateGroupSelect();
const $section2 = $('<div>', {
style: 'border-radius: 7px; background: #d6e6f3; margin:8px 8px 8px 8px; padding:8px 8px 8px 8px; box-shadow:0 1px 5px #0001;',
}).append(
$('<div>', { style: 'font-size:14.5px;font-weight:700;color:#355870;margin-bottom:10px;' }).text('My Saved Groups'),
$('<div>', { style: 'margin-bottom:8px;' }).append($groupSelect),
$('<div>', { style: 'display:flex;gap:12px;align-items:center;margin-top:6px;' }).append(
$('<button>', {
class: 'GISGroupDlg-btn',
style: BTN_STYLE_GREEN,
title: 'Load selected group',
})
.text('Load Group')
.on('click', function () {
const group = $groupSelect.val();
if (typeof group !== 'string' || !(settings.layerGroups && settings.layerGroups[group])) {
if (typeof WazeWrap !== 'undefined' && WazeWrap.Alerts && typeof WazeWrap.Alerts.info === 'function') {
WazeWrap.Alerts.info(scriptName, 'Please select a group to load.');
} else {
alert('Please select a group to load.');
}
return;
}
const grp = settings.layerGroups[group];
settings.selectedSubL1 = [...grp.selectedSubL1];
settings.visibleLayers = [...grp.visibleLayers];
settings.collapsedSections = { ...grp.collapsedSections };
settings.addrLabelDisplay = grp.addrLabelDisplay;
settings.fillParcels = grp.fillParcels;
saveSettingsToStorage();
loadSettingsFromStorage();
initGui(false);
$dlg.remove();
}),
$('<button>', {
class: 'GISGroupDlg-btn',
style: BTN_STYLE_ORANGE,
title: 'Delete selected group',
})
.text('Delete Group')
.on('click', function () {
const group = $groupSelect.val();
if (typeof group !== 'string' || !(settings.layerGroups && settings.layerGroups[group])) {
if (typeof WazeWrap !== 'undefined' && WazeWrap.Alerts && typeof WazeWrap.Alerts.info === 'function') {
WazeWrap.Alerts.info(scriptName, 'Please select a group to delete.');
} else {
alert('Please select a group to delete.');
}
return;
}
WazeWrap.Alerts.confirm(
scriptName,
'<div style="color:#ff0000; font-size:17px; font-weight:bold; padding:10px 0; text-align:Left;">' + 'Delete group "' + group + '"? \nThis cannot be undone!' + '</div>',
function () {
delete settings.layerGroups[group];
saveSettingsToStorage();
loadSettingsFromStorage();
populateGroupSelect();
setTimeout(function () {
if (typeof WazeWrap !== 'undefined' && WazeWrap.Alerts && typeof WazeWrap.Alerts.success === 'function') {
WazeWrap.Alerts.success(scriptName, 'Group "' + group + '" deleted.');
} else {
alert('Group "' + group + '" deleted.');
}
}, 150);
}
);
})
)
);
// Build and insert dialog
$dlg.append($section1, $section2);
$dlg.appendTo('body');
// Make draggable if possible
if (typeof jQuery.ui !== 'undefined') $dlg.draggable({ stop: () => $dlg.css('height', '') });
$(document).on('keydown.gisLayerDialog', function (e) {
if (e.key === 'Escape') $dlg.remove();
});
$dlg.on('remove', () => $(document).off('keydown.gisLayerDialog'));
}
/**
* Asynchronously loads GIS data for visible countries and subdivisions within the current map viewport.
*
* This function fetches data associated with countries and their subdivisions that are visible at the current zoom
* level. It avoids redundant data loads by tracking which countries and subdivisions have already been processed,
* thereby optimizing resource usage and enhancing loading efficiency.
*
* Process Overview:
* 1. Checks the current zoom level and returns early if below the threshold, preventing data loading.
* 2. Calls `whatsInView` to populate `_whatsInView` with currently visible country and subdivision data.
* 3. Iterates over `_whatsInView` to extract unique country codes (`ISO_ALPHA3`) and subdivision codes (`subL1_id`).
* 4. For each country code:
* - If it's not already loaded, initializes loading for all visible subdivisions.
* - For countries already loaded, filters subdivisions that haven't been loaded yet.
* - Calls `loadSpreadsheetAsync` to fetch and load the data and then updates the GUI.
* 5. Tracks loaded subdivisions to prevent redundancy and logs the loading activity for debugging.
*
* Features:
* - Efficiently manages GIS data loading based on visibility and ensures GUI updating post-data fetch.
* - Uses sets to maintain unique country and region codes, enhancing data consistency.
*
* Parameters:
* - No explicit parameters, utilizes global variables and state tracking.
*
* @returns {Promise<void>} - No explicit return; relies on side effects to update global state and UI.
*/
async function loadVisibleCountryData() {
try {
// Only load at suitable zoom levels
const currentZoomLevel = sdk.Map.getZoomLevel();
if (currentZoomLevel < 12) return;
await whatsInView();
/** @type {Set<string>} */
const countryCodes = new Set();
/** @type {Record<string, Set<string>>} */
const countryRegionCodes = {};
// Collect visible country and subdivision codes
for (const countryKey in _whatsInView) {
if (!_whatsInView.hasOwnProperty(countryKey)) continue;
const c = _whatsInView[countryKey];
if (!c?.ISO_ALPHA3) continue;
countryCodes.add(c.ISO_ALPHA3);
const regionSet = new Set();
if (c.subL1) {
for (const subCode in c.subL1) {
if (!c.subL1.hasOwnProperty(subCode)) continue;
const sub = c.subL1[subCode];
if (sub?.subL1_id) regionSet.add(sub.subL1_id);
}
}
countryRegionCodes[c.ISO_ALPHA3] = regionSet;
}
// For each country, determine which regions need loading
for (const isoCode of countryCodes) {
const regionCodes = countryRegionCodes[isoCode];
const newRegionCodesToLoad = new Set();
let needToLoad = false;
if (!alreadyLoadedCountries.has(isoCode)) {
// First load for this country
regionCodes.forEach((r) => newRegionCodesToLoad.add(r));
needToLoad = true;
} else {
// Already loaded; only new visible subdivisions
regionCodes.forEach((regionCode) => {
if (!alreadyLoadedSubL1.has(regionCode)) {
newRegionCodesToLoad.add(regionCode);
needToLoad = true;
}
});
}
if (needToLoad) {
await loadSpreadsheetAsync(isoCode, newRegionCodesToLoad);
alreadyLoadedCountries.add(isoCode);
initGui(false);
newRegionCodesToLoad.forEach((regionCode) => alreadyLoadedSubL1.add(regionCode));
}
}
} catch (error) {
logError(`Error in loadVisibleCountryData: ${error && error.message ? error.message : error}`);
throw error;
}
}
/**
* Compare two version strings ("2025.08.01.00", "2018.04.27.001")
* Returns 1 if a > b, -1 if a < b, 0 if equal
* @param {string} a
* @param {string} b
* @returns {number}
*/
function compareVersions(a, b) {
const splitA = a.split('.').map(Number);
const splitB = b.split('.').map(Number);
const maxLen = Math.max(splitA.length, splitB.length);
for (let i = 0; i < maxLen; i++) {
const numA = splitA[i] || 0;
const numB = splitB[i] || 0;
if (numA > numB) return 1;
if (numA < numB) return -1;
}
return 0;
}
/**
* Asynchronously loads GIS layer definitions from a Google Sheets spreadsheet.
*
* Fetches layer configuration data from a fixed tab in a Google Sheet using the Visualization API endpoint,
* then parses, filters, and augments the data based on the provided country ISO code and region codes.
* Returns an object with an error string if something goes wrong, or null if successful.
*
* @param {string} isoCode - Country ISO code, or "ALL" to load all layers.
* @param {Set<string>|string} regionCodes - Set of region/subdivision codes, or "ALL" to load for all.
* @returns {Promise<{ error: string|null }>} Promise resolving to { error } object.
*/
async function loadSpreadsheetAsync(isoCode, regionCodes) {
const TAB_NAME = 'Layer Definitions v2';
const SHEET_ID = '1cEG3CvXSCI4TOZyMQTI50SQGbVhJ48Xip-jjWg4blWw';
const LAYER_DEF_URL = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:json&sheet=${encodeURIComponent(TAB_NAME)}`;
const FIELD_INDEXES = {
country: 0,
subL1: 1,
name: 2,
id: 3,
subL2: 4,
url: 5,
where: 6,
labelFields: 7,
processLabel: 8,
style: 9,
visibleAtZoom: 10,
labelsVisibleAtZoom: 11,
enabled: 12,
restrictTo: 13,
oneTimeAlert: 14,
};
const REQUIRED_FIELDS = Object.keys(FIELD_INDEXES);
let dataObjects = [];
/** @type {{ error: string | null }} */
const result = { error: null };
try {
const resp = await fetch(LAYER_DEF_URL);
const text = await resp.text();
const match = text.match(/google\.visualization\.Query\.setResponse\(([\s\S]+)\);/);
if (!match) {
result.error = 'Failed to parse Google Sheet data!';
logError(result.error);
return result;
}
const json = JSON.parse(match[1]);
const allRows = json.table.rows;
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');
if (firstDataIdx === -1) {
result.error = 'Could not auto-detect start of data rows!';
logError(result.error);
return result;
}
function rowToObj(row) {
const obj = {};
for (let key of REQUIRED_FIELDS) {
const idx = FIELD_INDEXES[key];
const cell = row.c && row.c[idx];
let value = cell && cell.v !== undefined && cell.v !== null ? cell.v : null;
// Coerce known numeric fields
if (key === 'visibleAtZoom' || key === 'labelsVisibleAtZoom') {
obj[key] = value !== null && value !== undefined && value !== '' ? Number(value) : null;
} else {
obj[key] = value;
}
}
return obj;
}
dataObjects = allRows
.slice(firstDataIdx)
.map(rowToObj)
.filter((obj) => obj.country && obj.subL1);
// --- VERSION CHECK ---
let minVersion = '';
if (dataObjects.length && /^\d+\.\d+\.\d+\.\d+$/.test(dataObjects[0].country)) {
minVersion = dataObjects[0].country;
dataObjects = dataObjects.slice(1);
}
if (typeof scriptVersion !== 'undefined' && minVersion && compareVersions(scriptVersion, minVersion) < 0) {
result.error = `Script must be updated to at least version ${minVersion} before layer definitions can be loaded.`;
logError(result.error);
}
const loadAll = (typeof isoCode === 'string' && isoCode.toUpperCase() === 'ALL') || (typeof regionCodes === 'string' && regionCodes.toUpperCase() === 'ALL');
if (!loadAll && (!regionCodes || typeof regionCodes.has !== 'function')) {
regionCodes = new Set();
}
if (!result.error) {
dataObjects.forEach((row) => {
// Normalize the enabled column: only 1 gets enabled, everything else (including blank) is 0
let enabledVal = (row.enabled || '').toString().trim().toLowerCase();
row.enabled = enabledVal === '1' ? 1 : 0;
if (row.enabled !== 1) return; // Skip rows not enabled
// It's now always 1 or 0 across all rows
const layerDef = { enabled: row.enabled };
let countryId = '',
subL1Upper = '';
REQUIRED_FIELDS.forEach((fldName) => {
let value = row[fldName];
// Always assign zoom fields as numbers
if (fldName === 'visibleAtZoom' || fldName === 'labelsVisibleAtZoom') {
layerDef[fldName] = value !== null && value !== undefined && value !== '' ? Number(value) : null;
return;
}
// Special array fields
if ((fldName === 'subL2' || fldName === 'labelFields') && typeof value === 'string') {
layerDef[fldName] = value.split(',').map((item) => item.trim());
return;
}
// Special label processor
if (fldName === 'processLabel' && typeof value === 'string') {
try {
layerDef[fldName] = ESTreeProcessor.compile(`function __$proc(){${value}} __$proc();`);
} catch (ex) {
layerDef.labelProcessingError = true;
logError(`Error loading label processing function for layer "${layerDef.id}".`, ex);
}
return;
}
// Style parsing
if (fldName === 'style' && typeof value === 'string') {
layerDef.isRoadLayer = value === 'roads';
if (!layerDef.isRoadLayer && typeof LAYER_STYLES !== 'undefined' && !LAYER_STYLES.hasOwnProperty(value)) {
try {
value = JSON.parse(value);
} catch (ex) {
logError(`Invalid style definition for layer "${layerDef.id}".`, ex);
}
}
layerDef[fldName] = value;
return;
}
// Uppercase helpers
if (fldName === 'country' && typeof value === 'string') countryId = value.toUpperCase();
if (fldName === 'subL1' && typeof value === 'string') {
subL1Upper = value.toUpperCase();
layerDef[fldName] = subL1Upper;
return;
}
// RestrictTo parser
if (fldName === 'restrictTo' && typeof value === 'string') {
try {
const values = value.split(',').map((v) => v.trim().toLowerCase());
layerDef.notAllowed = !values.some((entry) => {
const rankMatch = entry.match(/^r(\d)(\+am)?$/);
if (rankMatch) {
if (rankMatch[1] <= userInfo.rank + 1 && (!rankMatch[2] || userInfo.isAreaManager)) {
return true;
}
} else if (entry === 'am' && userInfo.isAreaManager) {
return true;
} else if (entry === userInfo.userName?.toLowerCase()) {
return true;
}
return false;
});
} catch (ex) {
logError(ex);
}
layerDef.restrictTo = value;
return;
}
if (fldName === 'labelFields' && (!value || typeof value !== 'string')) {
layerDef[fldName] = [''];
return;
}
// Assign all other fields where value is not null/undefined
if (value !== undefined && value !== null) {
layerDef[fldName] = value;
}
});
if (typeof layerDef.url === 'string') {
const url = layerDef.url;
if (/\/rest\/(services|Shared)\//i.test(url) || /\/MapServer(\/\d*)?$/i.test(url) || /\/gis\/rest\//i.test(url)) {
layerDef.platform = 'ArcGIS';
} else if (/\/resource\/[a-z0-9-]+$/i.test(url)) {
layerDef.platform = 'SocrataV2';
} else if (/\/api\/v3\/views\/[a-z0-9-]+/i.test(url)) {
layerDef.platform = 'SocrataV3';
} else {
layerDef.platform = 'Other';
}
} else {
layerDef.platform = 'Other';
}
let validSubL1 = false;
if (loadAll) {
layerDef.countrySubL1 = `${layerDef.country || ''}-${layerDef.subL1 || ''}`;
validSubL1 = true;
} else {
if (countryId === isoCode.toUpperCase() && subL1Upper) {
layerDef['countrySubL1'] = `${countryId}-${subL1Upper}`;
}
validSubL1 = regionCodes && (regionCodes.has(subL1Upper) || subL1Upper === isoCode.toUpperCase());
}
if (validSubL1 && !layerDef.notAllowed) {
const layerExists = typeof _gisLayers !== 'undefined' && _gisLayers.some((existingLayer) => existingLayer.id === layerDef.id);
if (!layerExists && typeof _gisLayers !== 'undefined') {
_gisLayers.push(layerDef);
}
}
});
}
} catch (err) {
result.error = `Spreadsheet call failed. ${err && err.message ? err.message : err}`;
logError(result.error, err);
}
if (!dataObjects.length) {
result.error = 'Spreadsheet was empty or did not return any valid rows.';
logError(result.error);
return result;
}
return result;
}
/**
* @param {string} shortcutId
* @param {string} description
* @param {Function} callback
*/
function createShortcut(shortcutId, description, callback) {
let shortcutKeys = settings.shortcuts?.[shortcutId] ?? null;
if (shortcutKeys && sdk.Shortcuts.areShortcutKeysInUse({ shortcutKeys })) {
shortcutKeys = null;
}
sdk.Shortcuts.createShortcut({
shortcutId,
shortcutKeys, // may be null
description,
callback,
});
}
/**
* Initializes the GIS layers and related global state.
* On the first call, loads user info, settings, sets up shortcuts, GUI handlers, and event listeners.
* On every call, loads country and subdivision mappings and visible country data, updates the GUI and features.
*
* @async
* @param {boolean} [firstCall=true] - Whether this is the initial invocation (triggers full setup).
* @returns {Promise<void>} Resolves when initialization steps are complete.
*/
async function init(firstCall = true) {
_gisLayers = [];
_whatsInView = {};
alreadyLoadedCountries.clear();
alreadyLoadedSubL1.clear();
countrySubdivisionMapping = {};
if (firstCall) {
userInfo = sdk.State.getUserInfo();
labelProcessingGlobalVariables.sdk = sdk;
loadSettingsFromStorage();
createShortcut('toggleHnsOnly', 'Toggle HN-only address labels (GIS Layers)', onAddressDisplayShortcutKey);
createShortcut('toggleEnabled', 'Toggle display of GIS Layers', onToggleGisLayersShortcutKey);
installPathFollowingLabels();
window.addEventListener('beforeunload', saveSettingsToStorage, false);
_layerSettingsDialog = new LayerSettingsDialog();
}
const t0 = performance.now();
try {
await buildCountrySubdivisionMapping();
await loadVisibleCountryData();
logDebug(`Loaded ${_gisLayers.length} layer definitions in ${Math.round(performance.now() - t0)} ms.`);
initGui(firstCall);
await fetchFeatures();
$('#gis-layers-refresh').removeClass('fa-spin').css({ cursor: 'pointer' });
logDebug('Initialized.');
} catch (err) {
logError(err);
}
}
init();
/**
* Enhances OpenLayers SVG renderer to support path-following text labels on line features.
*
* After calling this function, styles can support:
* - pathLabel: {String} text drawn along the path
* - pathLabelXOffset: {String} start offset, px or %, default "50%"
* - pathLabelYOffset: {Number} vertical offset from the path
* - pathLabelCurve: {String} smooth path text (empty for none)
* - pathLabelReadable: {String} reverse direction if needed for readability
* - All standard label/text style values (color, font, outline, etc.)
*
* Internally:
* - Adds `pathText` for text-on-path SVG creation
* - Overrides `setStyle` to support path label styling and outline/halo
* - Overrides `drawGeometry` and `eraseGeometry` to clean up text paths
*
* Call once during startup before rendering vector layers with path labels.
*
* @returns {void}
* @copyright Jean-Marc Viglino, 2015 (CeCILL-B / Beerware License)
* @see http://www.cecill.info/
* @see http://en.wikipedia.org/wiki/Beerware
*/
function installPathFollowingLabels() {
/**
* Removes a child element with the specified id from a DOM node.
*
* Handles both standard and older browser DOM APIs.
*
* @param {Node} node - The parent DOM node.
* @param {string} id - The id of the child element to remove.
* @returns {void}
*/
function removeChildById(node, id) {
if (node.querySelector) {
var c = node.querySelector('#' + id);
if (c) node.removeChild(c);
return;
}
// For old browsers
var c = node.childNodes;
if (c)
for (var i = 0; i < c.length; i++) {
if (c[i].id === id) {
node.removeChild(c[i]);
return;
}
}
}
var setStyle = OpenLayers.Renderer.SVG.prototype.setStyle;
OpenLayers.Renderer.SVG.LABEL_STARTOFFSET = { l: '0%', r: '100%', m: '50%' };
/**
* Renders text as an SVG textPath following a geometry path.
*
* Applies OpenLayers/extra path label style options (see installPathFollowingLabels).
*
* @param {SVGElement} node - The SVG node representing the feature.
* @param {Object} style - Style object.
* @param {string} suffix - Suffix for unique element IDs.
* @returns {void}
*/
OpenLayers.Renderer.SVG.prototype.pathText = function (node, style, suffix) {
var label = this.nodeFactory(null, 'text');
label.setAttribute('id', node._featureId + '_' + suffix);
if (style.fontColor) label.setAttributeNS(null, 'fill', style.fontColor);
if (style.fontStrokeColor) label.setAttributeNS(null, 'stroke', style.fontStrokeColor);
if (style.fontStrokeWidth) label.setAttributeNS(null, 'stroke-width', style.fontStrokeWidth);
if (style.fontOpacity) label.setAttributeNS(null, 'opacity', style.fontOpacity);
if (style.fontFamily) label.setAttributeNS(null, 'font-family', style.fontFamily);
if (style.fontSize) label.setAttributeNS(null, 'font-size', style.fontSize);
if (style.fontWeight) label.setAttributeNS(null, 'font-weight', style.fontWeight);
if (style.fontStyle) label.setAttributeNS(null, 'font-style', style.fontStyle);
if (style.labelSelect === true) {
label.setAttributeNS(null, 'pointer-events', 'visible');
label._featureId = node._featureId;
} else {
label.setAttributeNS(null, 'pointer-events', 'none');
}
/**
* Parses a path string into an array of x/y points, optionally reversing for readability.
*
* @param {string} pathStr - The path string (comma-separated numbers).
* @param {boolean|string} readeable - If true, reverse the point order (for text readability).
* @returns {Array<{x: number, y: number}>} Array of point objects.
*/
function getpath(pathStr, readeable) {
var npath = pathStr.split(',');
var pts = [];
if (!readeable || Number(npath[0]) - Number(npath[npath.length - 2]) < 0) {
while (npath.length) pts.push({ x: Number(npath.shift()), y: Number(npath.shift()) });
} else {
while (npath.length) pts.unshift({ x: Number(npath.shift()), y: Number(npath.shift()) });
}
return pts;
}
var path = this.nodeFactory(null, 'path');
var tpid = node._featureId + '_t' + suffix;
var tpath = node.getAttribute('points');
if (style.pathLabelCurve) {
var pts = getpath(tpath, style.pathLabelReadable);
var p = pts[0].x + ' ' + pts[0].y;
var dx, dy, s1, s2;
dx = (pts[0].x - pts[1].x) / 4;
dy = (pts[0].y - pts[1].y) / 4;
for (var i = 1; i < pts.length - 1; i++) {
p += ' C ' + (pts[i - 1].x - dx) + ' ' + (pts[i - 1].y - dy);
dx = (pts[i - 1].x - pts[i + 1].x) / 4;
dy = (pts[i - 1].y - pts[i + 1].y) / 4;
s1 = Math.sqrt(Math.pow(pts[i - 1].x - pts[i].x, 2) + Math.pow(pts[i - 1].y - pts[i].y, 2));
s2 = Math.sqrt(Math.pow(pts[i + 1].x - pts[i].x, 2) + Math.pow(pts[i + 1].y - pts[i].y, 2));
p += ' ' + (pts[i].x + (s1 * dx) / s2) + ' ' + (pts[i].y + (s1 * dy) / s2);
dx *= s2 / s1;
dy *= s2 / s1;
p += ' ' + pts[i].x + ' ' + pts[i].y;
}
p += ' C ' + (pts[i - 1].x - dx) + ' ' + (pts[i - 1].y - dy);
dx = (pts[i - 1].x - pts[i].x) / 4;
dy = (pts[i - 1].y - pts[i].y) / 4;
p += ' ' + (pts[i].x + dx) + ' ' + (pts[i].y + dy);
p += ' ' + pts[i].x + ' ' + pts[i].y;
path.setAttribute('d', 'M ' + p);
} else {
if (style.pathLabelReadable) {
var pts = getpath(tpath, style.pathLabelReadable);
var p = '';
for (var i = 0; i < pts.length; i++) p += ' ' + pts[i].x + ' ' + pts[i].y;
path.setAttribute('d', 'M ' + p);
} else path.setAttribute('d', 'M ' + tpath);
}
path.setAttribute('id', tpid);
var defs = this.createDefs();
removeChildById(defs, tpid);
defs.appendChild(path);
var textPath = this.nodeFactory(null, 'textPath');
textPath.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#' + tpid);
var align = style.labelAlign || OpenLayers.Renderer.defaultSymbolizer.labelAlign;
label.setAttributeNS(null, 'text-anchor', OpenLayers.Renderer.SVG.LABEL_ALIGN[align[0]] || 'middle');
textPath.setAttribute('startOffset', style.pathLabelXOffset || OpenLayers.Renderer.SVG.LABEL_STARTOFFSET[align[0]] || '50%');
label.setAttributeNS(null, 'dominant-baseline', OpenLayers.Renderer.SVG.LABEL_ALIGN[align[1]] || 'central');
if (style.pathLabelYOffset) label.setAttribute('dy', style.pathLabelYOffset);
textPath.textContent = style.pathLabel;
label.appendChild(textPath);
removeChildById(this.textRoot, node._featureId + '_' + suffix);
this.textRoot.appendChild(label);
};
/**
* Sets style attributes on an SVG node, adding support for text labels following paths.
*
* If the geometry is a line and the style includes path label options,
* draws the label (and optional outline/halo) along the path.
*
* @param {SVGElement} node - The SVG node.
* @param {Object} style - Style object, can include path label options.
* @param {Object} [options] - Additional options (isFilled, isStroked, etc).
* @returns {SVGElement} The styled SVG node.
*/
OpenLayers.Renderer.SVG.prototype.setStyle = function (node, style, options) {
if (node._geometryClass === 'OpenLayers.Geometry.LineString' && style.pathLabel) {
var drawOutline = !!style.labelOutlineWidth;
// First draw text in halo color and size and overlay the
// normal text afterwards
if (drawOutline) {
var outlineStyle = OpenLayers.Util.extend({}, style);
outlineStyle.fontColor = outlineStyle.labelOutlineColor;
outlineStyle.fontStrokeColor = outlineStyle.labelOutlineColor;
outlineStyle.fontStrokeWidth = style.labelOutlineWidth;
if (style.labelOutlineOpacity) outlineStyle.fontOpacity = style.labelOutlineOpacity;
delete outlineStyle.labelOutlineWidth;
this.pathText(node, outlineStyle, 'txtpath0');
}
this.pathText(node, style, 'txtpath');
setStyle.apply(this, arguments);
} else setStyle.apply(this, arguments);
return node;
};
var drawGeometry = OpenLayers.Renderer.SVG.prototype.drawGeometry;
/**
* Draws a geometry, removing textPaths if geometry was not fully rendered.
*
* @param {OpenLayers.Geometry} geometry - Geometry to render.
* @param {Object} style - Style options.
* @param {string} id - Feature ID.
* @returns {boolean|null} True if geometry is drawn, null if incomplete, false otherwise.
*/
OpenLayers.Renderer.SVG.prototype.drawGeometry = function (geometry, style, id) {
var rendered = drawGeometry.apply(this, arguments);
if (rendered === false) {
removeChildById(this.textRoot, id + '_txtpath');
removeChildById(this.textRoot, id + '_txtpath0');
}
return rendered;
};
var eraseGeometry = OpenLayers.Renderer.SVG.prototype.eraseGeometry;
/**
* Erases geometry from the renderer and removes associated textPath labels from the DOM.
*
* @param {OpenLayers.Geometry} geometry - Geometry to erase.
* @param {string} featureId - Feature ID.
* @returns {void}
*/
OpenLayers.Renderer.SVG.prototype.eraseGeometry = function (geometry, featureId) {
eraseGeometry.apply(this, arguments);
removeChildById(this.textRoot, featureId + '_txtpath');
removeChildById(this.textRoot, featureId + '_txtpath0');
};
}
})();