您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds GIS layers in WME
/* 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'); }; } })();