diff --git a/backend/Data/_Processed/ambulance/ambulance_colors.parquet b/backend/Data/_Processed/ambulance/ambulance_colors.parquet
new file mode 100644
index 00000000..fbd55f1a
Binary files /dev/null and b/backend/Data/_Processed/ambulance/ambulance_colors.parquet differ
diff --git a/backend/Data/_Processed/ambulance/ambulance_geom.parquet b/backend/Data/_Processed/ambulance/ambulance_geom.parquet
new file mode 100644
index 00000000..f17b1739
Binary files /dev/null and b/backend/Data/_Processed/ambulance/ambulance_geom.parquet differ
diff --git a/backend/Data/_Processed/ambulance/ambulance_info.parquet b/backend/Data/_Processed/ambulance/ambulance_info.parquet
new file mode 100644
index 00000000..84ae316c
Binary files /dev/null and b/backend/Data/_Processed/ambulance/ambulance_info.parquet differ
diff --git a/backend/Data/ambulance/ambulance_service_areas.parquet b/backend/Data/ambulance/ambulance_service_areas.parquet
new file mode 100644
index 00000000..2923b787
Binary files /dev/null and b/backend/Data/ambulance/ambulance_service_areas.parquet differ
diff --git a/backend/api/routes/get_routes/get_legend.py b/backend/api/routes/get_routes/get_legend.py
index ac9e4e0a..b531752b 100644
--- a/backend/api/routes/get_routes/get_legend.py
+++ b/backend/api/routes/get_routes/get_legend.py
@@ -4,6 +4,7 @@
from query import (
get_soil_suit_legend,
+ get_ambulance_legend,
)
logger = logging.getLogger(__name__)
@@ -15,3 +16,9 @@
async def wastewater_soil_suit_legend():
data = get_soil_suit_legend()
return Response(content=data, media_type="application/json")
+
+
+@router.get("/load/mapping/ambulance/ambulance_legend")
+async def ambulance_legend():
+ data = get_ambulance_legend()
+ return Response(content=data, media_type="application/json")
diff --git a/backend/api/routes/post_routes/__init__.py b/backend/api/routes/post_routes/__init__.py
index 321b9823..1bc242b7 100644
--- a/backend/api/routes/post_routes/__init__.py
+++ b/backend/api/routes/post_routes/__init__.py
@@ -5,6 +5,7 @@
from .post_qcew import router as post_qcew_router
from .post_zoning import router as post_zoning_router
from .post_wastewater import router as post_wastewater_router
+from .post_ambulance import router as post_ambulance_router
all_post_routers = [
post_zoning_router,
@@ -14,4 +15,5 @@
post_wastewater_router,
post_export_router,
post_cdc_router,
+ post_ambulance_router,
]
diff --git a/backend/api/routes/post_routes/post_ambulance.py b/backend/api/routes/post_routes/post_ambulance.py
new file mode 100644
index 00000000..5ab349d9
--- /dev/null
+++ b/backend/api/routes/post_routes/post_ambulance.py
@@ -0,0 +1,30 @@
+from fastapi import APIRouter, Response
+
+from api.core_functions import request_to_source, spec_to_source
+from api.models import FilterRequest, FilterSpec
+from query import (
+ get_ambulance_geojson,
+ get_ambulance_legend,
+)
+
+router = APIRouter()
+
+
+@router.post("/load/mapping/ambulance/service_area")
+async def ambulance_info_geojson(request: FilterRequest):
+ source = request_to_source(request, "ambulance_ambulance_info", "default")
+ data = get_ambulance_geojson([source])
+ return Response(content=data, media_type="application/json")
+
+
+@router.post("/load/mapping/ambulance/service_area_new")
+async def ambulance_info_geojson_new(specs: list[FilterSpec]):
+ sources = [spec_to_source(spec, "default") for spec in specs]
+ data = get_ambulance_geojson(sources)
+ return Response(content=data, media_type="application/json")
+
+
+@router.get("/load/mapping/ambulance/ambulance_legend")
+async def ambulance_legend():
+ data = get_ambulance_legend()
+ return Response(content=data, media_type="application/json")
diff --git a/backend/api/schema.json b/backend/api/schema.json
index e019a8e3..4aa5c46c 100644
--- a/backend/api/schema.json
+++ b/backend/api/schema.json
@@ -85,6 +85,14 @@
"Jurisdiction": "Municipal_Name",
"Town": "TownName"
}
+ },
+ "ambulance_ambulance_info": {
+ "join_key":"OBJECTID",
+ "join_type": "inner",
+ "columns": {
+ "Zip Code": "Zip_Code",
+ "City or Town": "City"
+ }
}
}
}
diff --git a/backend/build/ambulance.py b/backend/build/ambulance.py
new file mode 100644
index 00000000..6a97ec4b
--- /dev/null
+++ b/backend/build/ambulance.py
@@ -0,0 +1,117 @@
+"""
+**Author**:
+ Atticus Tarleton
+**Created**:
+ 2026-07-20
+**Description**:
+ Build script to convert the ambulance service area files into SQL tables.
+"""
+
+import os
+from pathlib import Path
+
+import duckdb
+
+_project_root = Path.cwd()
+while not (_project_root / "api").exists():
+ _project_root = _project_root.parent
+os.chdir(_project_root)
+print(os.getcwd())
+
+# globals
+con = duckdb.connect()
+proc_dir = Path("Data/_Processed/ambulance")
+data_dir = Path("Data/ambulance/ambulance_service_areas.parquet")
+
+# hardcoded specifics:
+ambulance_info_cols = [
+ "OBJECTID",
+ "Serv_Name",
+ "Cert_Level",
+ "Address",
+ "Street_1",
+ "Street_2",
+ "City",
+ "State",
+ "Zip_Code",
+ "Total_Tran",
+ "Per_No_Tran",
+ "Re_Per_Tran",
+ "Cost_Per",
+ "Cost_Call",
+]
+
+ambulance_geom_cols = [
+ "OBJECTID",
+ "Shape__Area",
+ "Shape__Length",
+ "geometry",
+]
+
+
+# functions:
+def load_ambulance_data():
+ con.execute("LOAD spatial")
+
+ con.execute(f"""--sql
+ CREATE OR REPLACE VIEW ambulance_service_areas AS
+ SELECT * FROM read_parquet('{data_dir}')
+ """)
+
+
+def build_ambulance_info_table():
+ info_string = ", ".join(ambulance_info_cols)
+
+ con.execute(f"""--sql
+ CREATE OR REPLACE VIEW ambulance_info AS
+ SELECT {info_string}
+ FROM ambulance_service_areas
+ """)
+
+
+def build_ambulance_geom_table():
+ geom_string = ", ".join(ambulance_geom_cols)
+
+ con.execute(f"""--sql
+ CREATE OR REPLACE VIEW ambulance_geom AS
+ SELECT {geom_string}
+ FROM ambulance_service_areas
+ """)
+
+
+def build_ambulance_color_table():
+ con.execute("""--sql
+ CREATE TABLE ambulance_colors (
+ certification_level TEXT PRIMARY KEY,
+ hex_color TEXT NOT NULL,
+ rgba TEXT NOT NULL -- '[255,127,14,180]' as JSON-ish text
+ );
+
+ INSERT INTO ambulance_colors VALUES
+ ('Paramedic', '#2ca02c', '[44, 160, 44, 180]'),
+ ('Advanced EMT', '#ffcc00', '[255, 204, 0, 180]'),
+ ('Paramedic - Critical Care Endorsement', '#fd7e14', '[253, 126, 20, 180]')
+ """)
+
+
+def save_ambulance_tables():
+ load_ambulance_data()
+ build_ambulance_info_table()
+ build_ambulance_geom_table()
+ build_ambulance_color_table()
+
+ for table in ["ambulance_info", "ambulance_geom", "ambulance_colors"]:
+ con.execute(
+ f"COPY (SELECT * FROM {table}) TO '{proc_dir / f'{table}.parquet'}' "
+ )
+
+
+## Putting everything together
+def main():
+ proc_dir.mkdir(parents=True, exist_ok=True)
+ con.execute("LOAD spatial")
+ save_ambulance_tables()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/build/cdc.py b/backend/build/cdc.py
index a5775d7d..2d7ab210 100644
--- a/backend/build/cdc.py
+++ b/backend/build/cdc.py
@@ -69,7 +69,7 @@ def build_places(name: str, path: Path, indicators: str) -> None:
df = CON.execute(sql).df()
# Needed to get rid of df variable assignment to pass ruff linting check
if name == "county":
- build_PCA_table(df)
+ pca_df = build_PCA_table(df)
pct_df = add_national_percentile(df) # df here is still national
df = df[df["StateAbbr"] == "VT"].copy()
df, edge_df = bin_measures(df, variable_col="Measure", value_col="Data_Value")
@@ -81,18 +81,18 @@ def build_places(name: str, path: Path, indicators: str) -> None:
CON.execute(f"""--sql
CREATE OR REPLACE TABLE {name}_places
AS SELECT *
- FROM df
+ FROM {df}
""")
CON.execute(f"""--sql
CREATE OR REPLACE TABLE {name}_edges
AS SELECT *
- FROM edge_df
+ FROM {edge_df}
""")
if name == "county":
CON.execute(f"""--sql
CREATE OR REPLACE TABLE {name}PcaData
AS SELECT *
- FROM pca_df
+ FROM {pca_df}
""")
diff --git a/backend/build/consolidate.py b/backend/build/consolidate.py
index 5067ca37..7d1500a7 100644
--- a/backend/build/consolidate.py
+++ b/backend/build/consolidate.py
@@ -16,7 +16,6 @@
"Data/_Processed/all_data-copy.duckdb",
]:
path = Path(__file__).parent.parent / db_path
- path.touch(exist_ok=True)
db = duckdb.connect(str(db_path))
db.execute("INSTALL SPATIAL")
db.execute("LOAD SPATIAL")
diff --git a/backend/build/main.py b/backend/build/main.py
index db6b7b85..6f213020 100644
--- a/backend/build/main.py
+++ b/backend/build/main.py
@@ -7,7 +7,7 @@
Runs all the build scripts one after the other.
"""
-from build import FIPS_data, acs5, cdc, wastewater, zoning
+from build import FIPS_data, acs5, ambulance, cdc, wastewater, zoning
def main():
@@ -18,6 +18,7 @@ def main():
FIPS_data.main()
zoning.main()
wastewater.main()
+ ambulance.main()
if __name__ == "__main__":
diff --git a/backend/build/wastewater.py b/backend/build/wastewater.py
index f0f2bcf5..f614a225 100644
--- a/backend/build/wastewater.py
+++ b/backend/build/wastewater.py
@@ -8,7 +8,6 @@
"""
# TODO: move the large SQL code to its own files rather than inside here
-# TODO: also, need to go create the consolidated duckDB database
import os
from pathlib import Path
diff --git a/backend/data_collection/ambulance.py b/backend/data_collection/ambulance.py
new file mode 100644
index 00000000..4b3a03d2
--- /dev/null
+++ b/backend/data_collection/ambulance.py
@@ -0,0 +1,42 @@
+import pandas as pd
+import requests
+from io import BytesIO
+from pyogrio import read_dataframe
+
+AMBULANCE_SERVICE_AREA = "https://services1.arcgis.com/BkFxaEFNwHqX3tAw/arcgis/rest/services/FS_VCGI_OPENDATA_Emergency_AmbulanceServiceAreas_SP_v1/FeatureServer/0/query?outFields=*&where=1%3D1&f=geojson"
+STORAGE_LOCATION = "../Data/ambulance"
+
+
+# ---------------------------------------------------------------------------
+# Ambulance API fetch
+# ---------------------------------------------------------------------------
+
+# Fetch ambulance data from goverment arcgis website (geojson files)
+
+
+def fetch_service_areas() -> pd.DataFrame | None:
+ r = requests.get(AMBULANCE_SERVICE_AREA, timeout=30)
+ r.raise_for_status()
+ df = read_dataframe(BytesIO(r.content))
+ return df
+
+
+# ---------------------------------------------------------------------------
+# Main scrape runner
+# ---------------------------------------------------------------------------
+
+
+def run_ambulance_scrape() -> None:
+ """
+ Fetch Wastewater data and save as parquet files.
+ """
+ service_areas = fetch_service_areas()
+
+ if service_areas is not None:
+ service_areas.to_parquet(
+ f"{STORAGE_LOCATION}/ambulance_service_areas.parquet", index=False
+ )
+
+
+if __name__ == "__main__":
+ run_ambulance_scrape()
diff --git a/backend/notebooks/ambulance/table_build.qmd b/backend/notebooks/ambulance/table_build.qmd
new file mode 100644
index 00000000..46dd6d40
--- /dev/null
+++ b/backend/notebooks/ambulance/table_build.qmd
@@ -0,0 +1,175 @@
+---
+title: "ambulance data sql workthrough"
+author: Atticus Tarleton
+date: today
+description: builds SQL tables from wastewater data.
+format:
+ html:
+ html-math-method: mathjax
+ fig-responsive: true
+ toc: true
+ toc-location: left
+ theme: cosmo
+ page-layout: full
+ ipynb:
+ wrap: none
+execute:
+ cache: true
+editor:
+ render-on-save: true
+---
+
+# Setup
+
+Navigate to root:
+```{python}
+import sys, os
+from pathlib import Path
+
+_project_root = Path.cwd()
+while not (_project_root / "api").exists():
+ _project_root = _project_root.parent
+os.chdir(_project_root)
+print(os.getcwd())
+```
+
+```{python}
+import pandas as pd
+import geopandas as gpd
+import pyogrio
+from pathlib import Path
+import duckdb
+import re
+
+from app_utils.data_loading import crs_set
+```
+
+```{python}
+con = duckdb.connect()
+```
+
+# Examining the ambulance dataset
+
+```{python}
+data_dir= Path("Data")
+path = data_dir / "ambulance" / "ambulance_service_areas.parquet"
+ambulance_data = gpd.read_parquet(path)
+ambulance_data = crs_set(ambulance_data)
+
+display(ambulance_data.head())
+print(f'The number of rows is: {len(ambulance_data)}')
+```
+
+# Reading the datasets into SQL
+
+```{python}
+con.execute("INSTALL spatial; LOAD spatial")
+```
+
+```{python}
+con.execute(f"""--sql
+ CREATE OR REPLACE VIEW ambulance_service_areas AS
+ SELECT * FROM read_parquet('{path}')
+""")
+```
+now checking that this worked, and getting column names
+
+As we can see from the column names below, the Cov_Area columns seem rather arbitrary. What they really
+correspond to is different towns that are covered by the ambulance service provider. But they don't correspond
+in a 1 to 1 fashion, rather if a service provider covers 17 towns, then those 17 columns each have one town name.
+To fix this, there are two options. One is to create a new column called towns_serviced as it would hold a list
+of the towns serviced by the given provider. The other is to create a column for each town, and each column would
+be a binary yes no variable representing whether the given provider services that town
+
+```{python}
+df = con.execute("SELECT * FROM ambulance_service_areas").df()
+for col in df.columns:
+ print(col)
+
+display(df.head())
+```
+
+
+Now to separate the table
+
+`OBJECTID` will be the main hash key
+
+There are two tables, Information and Geometry
+- INFORMATION
+ - main hash key: `OBJECTID`
+ - goal: store all the useful information besides the geometry data
+- GEOMETRY
+ - main hash key: `OBJECTID`
+ - goal: store the geometry data because it is so large
+
+```{python}
+info_cols = [
+ "OBJECTID",
+ "Serv_Name",
+ "Cert_Level",
+ "Address",
+ "Street_1",
+ "Street_2",
+ "City",
+ "State",
+ "Zip_Code",
+ "Total_Tran",
+ "Per_No_Tran",
+ "Re_Per_Tran",
+ "Cost_Per",
+ "Cost_Call"
+ #figure out how to solve the cov area problem
+ # for right now can just ignore it as the area does not make a big difference on anything
+]
+
+geom_cols = [
+ "OBJECTID",
+ "Shape__Area",
+ "Shape__Length",
+ "geometry"
+]
+```
+
+Creating the ambulance info table
+```{python}
+ambulance_info_string = ", ".join(info_cols)
+
+con.execute(f"""--sql
+ CREATE OR REPLACE VIEW ambulance_info AS
+ SELECT {ambulance_info_string}
+ FROM ambulance_service_areas
+""")
+
+ambulance_info = con.execute("SELECT * FROM ambulance_info").df()
+display(ambulance_info.head())
+```
+
+Creating the ambulance geometry table
+```{python}
+ambulance_geom_string = ", ".join(geom_cols)
+
+con.execute(f"""--sql
+ CREATE OR REPLACE VIEW ambulance_geom AS
+ SELECT {ambulance_geom_string}
+ FROM ambulance_service_areas
+""")
+
+ambulance_geom = con.execute("SELECT * FROM ambulance_geom").df()
+display(ambulance_geom.head())
+```
+
+creating a color table for fill colors
+```{python}
+con.execute(f"""--sql
+CREATE TABLE ambulance_cert_level_colors (
+ certification_level TEXT PRIMARY KEY,
+ hex_color TEXT NOT NULL,
+ rgba TEXT NOT NULL -- '[255,127,14,180]' as JSON-ish text
+);
+
+INSERT INTO ambulance_cert_level_colors VALUES
+ ('Paramedic', '#2ca02c', '[44, 160, 44, 180]'),
+ ('Advanced EMT', '#ffcc00', '[255, 204, 0, 180]'),
+ ('Paramedic - Critical Care Endorsement', '#fd7e14', '[253, 126, 20, 180]')
+""")
+```
\ No newline at end of file
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 0164ba5b..8fac66dc 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -37,6 +37,7 @@ dev = [
"nbformat>=5.10.4",
"plotly>=6.9.0",
"pytest>=8.3.0",
+ "ruff>=0.16.1",
"scikit-learn>=1.9.0",
"seaborn>=0.13.2",
"shapely>=2.1.2",
diff --git a/backend/query/__init__.py b/backend/query/__init__.py
index f4ed354d..c9822aeb 100644
--- a/backend/query/__init__.py
+++ b/backend/query/__init__.py
@@ -1,5 +1,9 @@
# ruff: noqa: F401
+from query.ambulance import (
+ get_ambulance_geojson,
+ get_ambulance_legend,
+)
from query.cdc import dual_var_comparison, get_cdc_county_pca, single_var_geojson
from query.core_functions import filter_options, filter_tree
from query.processed_db import DB
diff --git a/backend/query/ambulance.py b/backend/query/ambulance.py
new file mode 100644
index 00000000..7923a2e7
--- /dev/null
+++ b/backend/query/ambulance.py
@@ -0,0 +1,37 @@
+"""
+**Author**:
+ Atticus Tarleton
+**Created**:
+ 2026-07-20
+**Description**:
+ Functions for serving ambulance data to the API from the parquet files.
+"""
+
+import logging
+from pathlib import Path
+
+from api.models import FilterSource
+from app_utils.sql_render import sql_filter_block
+from query.processed_db import DB
+
+logger = logging.getLogger(__name__)
+sql_dir = Path(__file__).resolve().parent / "sql" / "ambulance"
+
+
+def get_ambulance_geojson(sources: list[FilterSource]):
+ sql, params = sql_filter_block(sql_dir / "ambulance_geo_query.sql", sources)
+ result = DB.execute(sql, params).fetchone()
+ if result is None:
+ logger.error("geo query returned no rows for filters: %s", sources)
+ raise ValueError(f"no results for filters: {sources}")
+ return result[0]
+
+
+def get_ambulance_legend():
+ result = DB.execute(
+ "SELECT json_group_array(to_json(ambulance_ambulance_colors)) FROM ambulance_ambulance_colors;"
+ ).fetchone()
+ if result is None:
+ logger.error("color query returned no rows for the colors dataset")
+ raise ValueError("no results for colors dataset")
+ return result[0]
diff --git a/backend/query/sql/ambulance/ambulance_geo_query.sql b/backend/query/sql/ambulance/ambulance_geo_query.sql
new file mode 100644
index 00000000..0d1338fb
--- /dev/null
+++ b/backend/query/sql/ambulance/ambulance_geo_query.sql
@@ -0,0 +1,30 @@
+{{ cte_filter_block }}
+SELECT
+ json_object(
+ 'type', 'FeatureCollection',
+ 'features', json_group_array(feature)
+ )::VARCHAR AS fc
+FROM (
+ SELECT
+ json_object(
+ 'type', 'Feature',
+ 'geometry', ST_AsGeoJSON(ST_Simplify(g.geometry, 0.0001))::JSON,
+ 'properties', json_object(
+ 'Certification Level', i.Cert_Level,
+ 'Acres', ROUND(g.Shape__Area, 2),
+ 'rgba_color', c.rgba::JSON,
+ 'tooltip', json_object(
+ '__title__', 'Ambulance Service Areas',
+ 'Company Name', i.Serv_Name,
+ 'Address of Company', i.Address,
+ 'Certification Level', i.Cert_Level,
+ 'Acres', ROUND(g.Shape__Area, 2)
+ )
+ )
+ ) AS feature
+ FROM ambulance_ambulance_info AS i
+ INNER JOIN ambulance_ambulance_geom AS g USING (OBJECTID)
+ LEFT JOIN ambulance_ambulance_colors AS c
+ ON i.Cert_Level = c.certification_level
+ {{ join_filter_block }}
+) AS features
\ No newline at end of file
diff --git a/backend/tests/test_sql_render.py b/backend/tests/test_sql_render.py
index 76fa137b..bfd561ac 100644
--- a/backend/tests/test_sql_render.py
+++ b/backend/tests/test_sql_render.py
@@ -61,19 +61,13 @@
)
],
"query/sql/zoning/agg_info_table.sql": [CTE_SOURCE],
- "query/sql/zoning/agg_rules_table.sql": [CTE_SOURCE],
"query/sql/zoning/info_table.sql": [CTE_SOURCE],
"query/sql/zoning/geo_query.sql": [CTE_SOURCE],
"query/sql/zoning/unzoned.sql": [],
"query/sql/zoning/agg_rules_table.sql": [CTE_SOURCE],
"query/sql/zoning/geo_rule_table.sql": [CTE_SOURCE],
"query/sql/zoning/rules_table.sql": [CTE_SOURCE],
- "query/sql/wastewater/service_area_geo_query.sql": [CTE_SOURCE],
- "query/sql/wastewater/soil_suitability_geo_query.sql": [CTE_SOURCE],
- "query/sql/wastewater/waste_treatment_geo_query.sql": [CTE_SOURCE],
- "query/sql/wastewater/waste_treatment_permit_table.sql": [CTE_SOURCE],
"query/sql/zoning/rules.sql": [CTE_SOURCE],
- "query/sql/zoning/rules_table.sql": [CTE_SOURCE],
"query/sql/wastewater/service_area_geo_query.sql": [
FilterSource(
filter_table="service_areas_service_area_info",
diff --git a/backend/uv.lock b/backend/uv.lock
index 3d166877..a9e91e03 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -97,6 +97,7 @@ dev = [
{ name = "nbformat" },
{ name = "plotly" },
{ name = "pytest" },
+ { name = "ruff" },
{ name = "scikit-learn" },
{ name = "seaborn" },
{ name = "shapely" },
@@ -130,6 +131,7 @@ dev = [
{ name = "nbformat", specifier = ">=5.10.4" },
{ name = "plotly", specifier = ">=6.9.0" },
{ name = "pytest", specifier = ">=8.3.0" },
+ { name = "ruff", specifier = ">=0.16.1" },
{ name = "scikit-learn", specifier = ">=1.9.0" },
{ name = "seaborn", specifier = ">=0.13.2" },
{ name = "shapely", specifier = ">=2.1.2" },
@@ -163,7 +165,7 @@ name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+ { name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
@@ -1066,7 +1068,7 @@ name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "ptyprocess" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
@@ -1690,6 +1692,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
]
+[[package]]
+name = "ruff"
+version = "0.16.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" },
+ { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" },
+ { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" },
+ { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" },
+]
+
[[package]]
name = "scikit-learn"
version = "1.9.0"
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
index 20e7bcfb..c4b7818f 100644
--- a/frontend/next-env.d.ts
+++ b/frontend/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import './.next/dev/types/routes.d.ts';
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/frontend/src/app/mapping/[slug]/page.tsx b/frontend/src/app/mapping/[slug]/page.tsx
index 4bc8580e..f0cf3ba1 100644
--- a/frontend/src/app/mapping/[slug]/page.tsx
+++ b/frontend/src/app/mapping/[slug]/page.tsx
@@ -7,6 +7,7 @@ export function generateStaticParams() {
{ slug: 'treatment-facilities' },
{ slug: 'service-areas' },
{ slug: 'flood-legal' },
+ { slug: 'ambulance' },
];
}
diff --git a/frontend/src/app/mapping/[slug]/page_content.tsx b/frontend/src/app/mapping/[slug]/page_content.tsx
index e2e7bd8f..d66768b5 100644
--- a/frontend/src/app/mapping/[slug]/page_content.tsx
+++ b/frontend/src/app/mapping/[slug]/page_content.tsx
@@ -53,6 +53,12 @@ const MAP_CONFIG: Record<
filterURL: `${BASE_API_URL}/filters/tree?filter_table=service_areas_service_area_info`,
dataURL: `${BASE_API_URL}/load/mapping/wastewater/service_area`,
},
+ ambulance: {
+ title: 'Ambulance Service Areas',
+ filterURL: `${BASE_API_URL}/filters/tree?filter_table=ambulance_ambulance_info`,
+ dataURL: `${BASE_API_URL}/load/mapping/ambulance/service_area`,
+ legendURL: `${BASE_API_URL}/load/mapping/ambulance/ambulance_legend`,
+ },
};
export default function MappingContent() {
@@ -62,6 +68,7 @@ export default function MappingContent() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [showCountyLines, setShowCountyLines] = useState(true);
+ const [largeBorder, setLargeBorder] = useState(false);
const [legendData, setLegendData] = useState([]);
@@ -102,6 +109,8 @@ export default function MappingContent() {
.finally(() => setLoading(false)); //say that loading is done
}, [slug, config?.legendURL]); //do only once on initial render ideally
+ const borderSwitchText = `Show ${config?.title ?? slug}`;
+
return (
@@ -134,6 +143,13 @@ export default function MappingContent() {
}
label="Show Town Borders"
/>
+
+ setLargeBorder(event.currentTarget.checked)
+ }
+ label={borderSwitchText}
+ />
@@ -164,7 +180,11 @@ export default function MappingContent() {
}}
>
-
+
diff --git a/frontend/src/app/mapping/page.tsx b/frontend/src/app/mapping/page.tsx
index 9a3f1dcf..78318318 100644
--- a/frontend/src/app/mapping/page.tsx
+++ b/frontend/src/app/mapping/page.tsx
@@ -39,6 +39,13 @@ export const links = [
'Identify FEMA flood hazard areas and understand development and insurance implications.',
badges: ['FEMA', 'Flood Risk'],
},
+ {
+ link: '/mapping/ambulance',
+ label: 'Ambulance Service Areas',
+ description:
+ 'Cover ambulance service areas in Vermont and the summary statistics of their calls',
+ badges: ['Health'],
+ },
];
export default function BaseMappingPage() {
diff --git a/frontend/src/components/HeaderMenu/index.tsx b/frontend/src/components/HeaderMenu/index.tsx
index 585ce0b4..99cb3aed 100644
--- a/frontend/src/components/HeaderMenu/index.tsx
+++ b/frontend/src/components/HeaderMenu/index.tsx
@@ -33,6 +33,7 @@ const links = [
label: 'Wastewater System Service Areas',
},
{ link: '/mapping/flood-legal', label: 'Flood Insurance' },
+ { link: '/mapping/ambulance', label: 'Ambulance Service Areas' },
],
},
{ link: '/data-viewer', label: 'Analyze' }, // accessible via Working Report
diff --git a/frontend/src/components/mapping/index.tsx b/frontend/src/components/mapping/index.tsx
index 4b1c6448..08e73988 100644
--- a/frontend/src/components/mapping/index.tsx
+++ b/frontend/src/components/mapping/index.tsx
@@ -24,6 +24,7 @@ interface MyMapProps {
* own `rgba_color` and `tooltip` properties, exactly like the main layer.
*/
baseGeojson?: FeatureCollection | null;
+ largeBorders: boolean;
}
const BASE_STYLES = {
@@ -55,6 +56,7 @@ export default function VTMap({
controllerOn = true,
initialZoom = 7,
baseGeojson = null,
+ largeBorders,
}: MyMapProps) {
const [viewState, setViewState] = useState({
...INITIAL_VIEW_STATE,
@@ -113,6 +115,23 @@ export default function VTMap({
}
};
+ const [lineWidth, setLineWidth] = useState(0.5);
+ const [lineColor, setLineColor] = useState<[number, number, number, number]>([
+ 80, 80, 80, 80,
+ ]);
+
+ // Changes the line width and color if there are large borders
+ // done this way so that things don't have infinite loops. not sure if this is the best practice though
+ useEffect(() => {
+ if (largeBorders) {
+ setLineWidth(3);
+ setLineColor([0, 0, 0, 100]);
+ } else {
+ setLineWidth(0.5);
+ setLineColor([80, 80, 80, 80]);
+ }
+ }, [largeBorders]);
+
const getFillColor = (d: {
properties?: { rgba_color?: [number, number, number, number] };
}) => d.properties?.rgba_color ?? [0, 0, 0, 0];
@@ -139,8 +158,10 @@ export default function VTMap({
data: geojson,
filled: true,
getFillColor,
- getLineColor: [80, 80, 80, 80],
- lineWidthMinPixels: 0.5,
+ // getLineColor: [80, 80, 80, 80],
+ getLineColor: lineColor,
+ lineWidthMinPixels: lineWidth,
+ // lineWidthMinPixels: 0.5,
pickable: true,
autoHighlight: true,
highlightColor: [222, 102, 0, 200],