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],