ziptrend 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ziptrend/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Data preparation and aggregation for US ZIP-code exploration."""
2
+ from .data import read_data, prepare_data, summarize, trends, locate
3
+
4
+ __all__ = ["read_data", "prepare_data", "summarize", "trends", "locate"]
ziptrend/app.py ADDED
@@ -0,0 +1,212 @@
1
+ """Streamlit entry point, also installed with the package."""
2
+ import hashlib
3
+ import pandas as pd
4
+ import plotly.express as px
5
+ import streamlit as st
6
+ from ziptrend.data import read_data, prepare_data, locate, trends, time_bins, period_snapshot
7
+ from ziptrend.demo import demo_data
8
+ from ziptrend.charts import map_chart, COLORS
9
+ from ziptrend.selection import parse_zip_list
10
+
11
+ st.set_page_config(page_title="ZIPtrend · Geography over time", page_icon="◈", layout="wide")
12
+ # Let Streamlit pair foregrounds and surfaces for the active light/dark theme.
13
+ # Fixed light surfaces break contrast when the browser selects a dark theme.
14
+ st.markdown("""<style>
15
+ h1 {letter-spacing: -1.5px;}
16
+ [data-testid="stMetric"] {
17
+ padding:18px;
18
+ border:1px solid currentColor;
19
+ border-color:color-mix(in srgb, currentColor 25%, transparent);
20
+ border-radius:14px;
21
+ }
22
+ [data-testid="stCaptionContainer"] {color:inherit; opacity:1;}
23
+ /* A timeline selects one bin, not a cumulative range. Keep its rail neutral. */
24
+ [data-testid="stSlider"]:has(input[aria-label="Map timeline"])
25
+ [data-rac][data-orientation="horizontal"] > div:first-child:not([data-rac]) {
26
+ background:color-mix(in srgb, currentColor 25%, transparent) !important;
27
+ }
28
+ </style>""", unsafe_allow_html=True)
29
+ st.caption("ZIPTREND / U.S. DATA EXPLORER")
30
+ st.write("Explore where values concentrate and how they change over time.")
31
+
32
+
33
+ @st.cache_data(show_spinner=False)
34
+ def resolve_geography(data):
35
+ return locate(data)
36
+
37
+
38
+ def guess(columns, names):
39
+ return next((i for i, c in enumerate(columns) if str(c).lower() in names), 0)
40
+
41
+
42
+ def zip_picker(label, options, key):
43
+ """Searchable selection with bulk entry and a predictable clear action."""
44
+ st.session_state[key] = [z for z in st.session_state.get(key, []) if z in options]
45
+ with st.expander("Paste a ZIP list", expanded=False):
46
+ pasted = st.text_area("ZIPs separated by commas, spaces, or newlines", key=f"{key}_paste",
47
+ placeholder="02108, 80202, 80203")
48
+ if st.button("Add ZIP list", key=f"{key}_add"):
49
+ accepted, rejected = parse_zip_list(pasted, options)
50
+ st.session_state[key] = list(dict.fromkeys(st.session_state[key] + accepted))
51
+ if rejected:
52
+ st.warning("Not available in the current filters or invalid: " + ", ".join(rejected[:20]))
53
+ if accepted:
54
+ st.success(f"Added {len(accepted)} matching ZIP(s).")
55
+ if st.button("Clear ZIP selection", key=f"{key}_clear"):
56
+ st.session_state[key] = []
57
+ selected = st.multiselect(label, options, key=key, placeholder="Type a ZIP or prefix to search…",
58
+ help="Type to narrow the list; select multiple ZIPs. Use Paste a ZIP list for bulk entry.")
59
+ st.caption(f"{len(selected):,} selected · {len(options):,} available")
60
+ return selected
61
+
62
+
63
+ with st.sidebar:
64
+ st.header("Your data")
65
+ uploaded = st.file_uploader("Upload observations", type=["csv", "tsv", "txt", "xlsx", "xls", "json", "jsonl"])
66
+ raw_demo, demo_lookup = demo_data()
67
+ st.download_button("Download sample CSV", raw_demo.to_csv(index=False), "sample_population.csv", "text/csv")
68
+ lookup_file = st.file_uploader("Optional offline ZIP lookup", type=["csv"], help="Columns: zip, state (two-letter code), latitude, longitude. One row per ZIP.")
69
+ try:
70
+ if uploaded is None:
71
+ raw = raw_demo
72
+ source_key = "demo"
73
+ st.info("Showing fictional sample population, 2023–2025.")
74
+ else:
75
+ source_key = hashlib.sha256(uploaded.getvalue()).hexdigest()[:12]
76
+ sheet = 0
77
+ delimiter = None
78
+ if uploaded.name.lower().endswith((".xlsx", ".xls")):
79
+ sheet = st.selectbox("Worksheet", pd.ExcelFile(uploaded).sheet_names)
80
+ if uploaded.name.lower().endswith((".txt", ".csv")):
81
+ delimiter = {"Auto": None, "Comma": ",", "Tab": "\t", "Semicolon": ";", "Pipe": "|"}[st.selectbox("Delimiter", ["Auto", "Comma", "Tab", "Semicolon", "Pipe"])]
82
+ raw = read_data(uploaded, sheet_name=sheet, delimiter=delimiter)
83
+ columns = list(raw.columns)
84
+ if len(columns) < 3:
85
+ st.error("Provide at least a ZIP, date, and numeric value column.")
86
+ st.stop()
87
+ zip_col = st.selectbox("ZIP column", columns, index=guess(columns, {"zip", "zipcode", "zip_code", "postal_code"}), key=f"zip_{source_key}")
88
+ date_col = st.selectbox("Date column", columns, index=guess(columns, {"date", "month", "year"}), key=f"date_{source_key}")
89
+ val_col = st.selectbox("Measure", columns, index=guess(columns, {"population", "value", "count"}), key=f"value_{source_key}")
90
+ date_format = st.text_input("Date format (optional)", placeholder="%m/%d/%Y", help="Use %Y for years. ISO YYYY-MM-DD is recommended; otherwise automatic parsing uses month-first dates.")
91
+ duplicates = st.selectbox("Repeated ZIP/date rows", ["error", "sum", "mean", "last"])
92
+ data = prepare_data(raw, zip_column=zip_col, date_column=date_col, value_column=val_col,
93
+ date_format=date_format or None, duplicate=duplicates)
94
+ if lookup_file:
95
+ data = locate(data, read_data(lookup_file))
96
+ elif uploaded is None:
97
+ data = locate(data, demo_lookup)
98
+ else:
99
+ with st.spinner("Finding ZIP geography…"):
100
+ data = resolve_geography(data)
101
+ except Exception as exc:
102
+ st.error(f"Unable to load data: {exc}")
103
+ st.caption("ZIP lookup needs internet on first use. Upload an offline lookup if unavailable.")
104
+ st.stop()
105
+ st.divider()
106
+ st.header("Explore")
107
+ states = st.multiselect("States · empty means all", sorted(data.state.dropna().unique()), key=f"states_{source_key}")
108
+ regional = data[data.state.isin(states)] if states else data
109
+ zip_mode = st.selectbox("ZIP filter", ["Include selected ZIPs", "Exclude selected ZIPs"])
110
+ zips = zip_picker("ZIP codes · empty means all", sorted(regional.zip.unique()), f"zips_{source_key}")
111
+ if zips:
112
+ matches = regional.zip.isin(zips)
113
+ regional = regional[matches if zip_mode == "Include selected ZIPs" else ~matches]
114
+ frequency = st.selectbox("Time interval", ["month", "year", "day"])
115
+ method = st.selectbox("Aggregation", ["latest", "mean", "sum"], help="Aggregate observations per ZIP within each time interval. Latest is suitable for population snapshots; sum for additive observations such as transactions.")
116
+ st.caption("State totals represent only ZIPs in your data. Missing periods are not filled.")
117
+
118
+ if regional.empty:
119
+ st.info("No observations match these filters.")
120
+ st.stop()
121
+ unknown = regional.loc[regional[["latitude", "longitude", "state"]].isna().any(axis=1), "zip"].unique()
122
+ if len(unknown):
123
+ st.warning(f"{len(unknown)} ZIP(s) lack complete geography: {', '.join(unknown[:15])}. They remain in ZIP trends and exports; missing coordinates cannot appear on the map.")
124
+
125
+ dates = sorted(regional.date.dt.date.unique())
126
+ window = (dates[0], dates[-1])
127
+ with st.sidebar.expander("Optional date-range filter"):
128
+ limit_dates = st.checkbox("Limit date range", value=False)
129
+ if limit_dates and len(dates) > 1:
130
+ window = st.select_slider("Date range", options=dates, value=window,
131
+ key=f"range_{source_key}_{','.join(states)}_{','.join(zips)}_{zip_mode}")
132
+ st.caption("Limits the timeline, trend charts, and exports. Use the slider below the map to step through time.")
133
+ filtered = regional[regional.date.dt.date.between(*window)]
134
+ series = trends(filtered, frequency, method)
135
+
136
+ map_tab, trends_tab, table_tab = st.tabs(["Geography", "Trends & comparison", "Data & export"])
137
+ with map_tab:
138
+ mode = st.radio("Map style", ["ZIP markers", "Concentration heatmap"], horizontal=True)
139
+ fixed_scale = st.checkbox("Keep color scale fixed over time", value=False,
140
+ help="Off: rescale to the current filtered interval. On: use all intervals within the current state/ZIP filters for easier time comparisons.")
141
+ summary_area = st.container()
142
+ map_area = st.container()
143
+ periods = [value.to_pydatetime() for value in time_bins(filtered, frequency)]
144
+ timeline_key = "timeline_" + hashlib.sha256(str((source_key, frequency, window, states, zips, zip_mode)).encode()).hexdigest()[:16]
145
+ period_label = lambda value: value.strftime({"month": "%B %Y", "year": "%Y", "day": "%b %d, %Y"}[frequency])
146
+ if len(periods) > 1:
147
+ period = st.select_slider("Map timeline", options=periods, value=periods[-1],
148
+ format_func=period_label, key=timeline_key,
149
+ help=f"Each step shows one {frequency}. Empty intervals remain visible; missing observations are never carried forward.")
150
+ else:
151
+ period = periods[0]
152
+ st.caption(f"Map timeline: {period_label(period)} — only one interval available.")
153
+ snapshot = period_snapshot(filtered, period, frequency, method)
154
+ with summary_area:
155
+ st.subheader(f"{' · '.join(states) if states else 'United States'} · {period_label(period)}")
156
+ metrics = st.columns(3)
157
+ metrics[0].metric("ZIPs in this interval", f"{snapshot.zip.nunique():,}")
158
+ metrics[1].metric(f"{val_col} · sum across ZIPs", f"{snapshot.value.sum():,.0f}" if not snapshot.empty else "No data")
159
+ metrics[2].metric("Aggregation", method.title())
160
+ st.caption(f"{method.title()} per ZIP within this {frequency}. Cool blue → warm pink/red means lower → higher values. Select markers to add ZIPs to the comparison.")
161
+ mapped = snapshot.dropna(subset=["latitude", "longitude"])
162
+ options = sorted(filtered.zip.unique())
163
+ focus_key = f"inspect_{source_key}"
164
+ map_key = "map_" + hashlib.sha256(str((source_key, mode, period, window, method, options, states)).encode()).hexdigest()[:16]
165
+
166
+ def add_map_selection():
167
+ points = st.session_state[map_key].selection.points
168
+ selected = [point["customdata"][0] for point in points if point.get("customdata")]
169
+ st.session_state[focus_key] = list(dict.fromkeys(st.session_state.get(focus_key, []) + selected))
170
+
171
+ with map_area:
172
+ render_mode = mode
173
+ if snapshot.empty:
174
+ st.info("No observations in this interval. Move the timeline to another interval.")
175
+ elif mapped.empty:
176
+ st.info("No coordinates available in this interval. Trends remain available.")
177
+ if mode == "Concentration heatmap" and (mapped.value < 0).any():
178
+ st.info("This interval contains negative values; showing ZIP markers instead of a concentration heatmap.")
179
+ render_mode = "ZIP markers"
180
+ scale_values = series[series.zip.isin(filtered.dropna(subset=["latitude", "longitude"]).zip)].value if fixed_scale else mapped.value
181
+ limits = (float(scale_values.min()), float(scale_values.max())) if not scale_values.empty else None
182
+ if limits and limits[0] == limits[1]:
183
+ padding = abs(limits[0]) * 0.05 or 1
184
+ limits = (limits[0] - padding, limits[1] + padding)
185
+ fig = map_chart(mapped, render_mode, limits, states=states, view_data=filtered)
186
+ st.plotly_chart(fig, width="stretch", on_select=add_map_selection if mode == "ZIP markers" else "ignore",
187
+ selection_mode="points", key=map_key)
188
+ st.caption("Colors use only the current state/ZIP filters" + (" across all timeline intervals." if fixed_scale else " and the displayed interval; the scale can change as you slide.") + " Heatmap intensity blends nearby values and varies with zoom; it is not population per unit area. Empty intervals show no values.")
189
+ st.caption("State outlines: U.S. Census Bureau, 2024 (generalized). ZIP boundaries are not included. Basemap © OpenStreetMap contributors © CARTO. ZIP centroids: GeoNames via pgeocode (CC BY 4.0).")
190
+ st.subheader("Inspect ZIP codes")
191
+ st.caption("Search or paste ZIPs to compare their histories. Clicking map markers adds ZIPs here. This selection affects only the comparison below; use the sidebar ZIP filter to filter the whole dashboard.")
192
+ focus = zip_picker("Inspect ZIP codes", options, focus_key)
193
+ if focus:
194
+ focus_data = series[series.zip.isin(focus)]
195
+ st.plotly_chart(px.line(focus_data, x="period", y="value", color="zip", markers=True, title=f"Selected ZIPs · {val_col}", labels={"value": str(val_col), "period": "Date", "zip": "ZIP"}), width="stretch")
196
+ else:
197
+ st.info("Select ZIP codes above or click map markers to compare their trends.")
198
+
199
+ with trends_tab:
200
+ group = st.radio("Compare", ["zip", "state"], horizontal=True)
201
+ lines = trends(filtered, frequency, method, group)
202
+ st.plotly_chart(px.line(lines, x="period", y="value", color=group, markers=True,
203
+ labels={"value": str(val_col), "period": "Date"}), width="stretch")
204
+ st.caption("Lines connect available observations; gaps do not mean zero. State curves sum the available ZIP values in each period; coverage can vary.")
205
+ st.plotly_chart(px.bar(snapshot.sort_values("value", ascending=False).head(25), x="zip", y="value", color="value",
206
+ color_continuous_scale=COLORS, title=f"Top ZIPs · {period_label(period)}" ).update_xaxes(type="category"), width="stretch")
207
+ with table_tab:
208
+ st.write("Observations in selected date range")
209
+ st.dataframe(filtered, hide_index=True, width="stretch")
210
+ st.download_button("Export filtered observations", filtered.to_csv(index=False), "ziptrend_observations.csv", "text/csv")
211
+ st.download_button("Export displayed map interval", snapshot.to_csv(index=False), f"ziptrend_{period:%Y-%m-%d}.csv", "text/csv")
212
+
@@ -0,0 +1,12 @@
1
+ # State outlines
2
+
3
+ U.S. Census Bureau, 2024 cartographic boundary files, 1:20,000,000 scale.
4
+ Source: https://www2.census.gov/geo/tiger/GENZ2024/shp/cb_2024_us_state_20m.zip
5
+ Documentation: https://www.census.gov/geographies/mapping-files/time-series/geo/carto-boundary-file.html
6
+
7
+ Converted from the Census shapefile to GeoJSON with `tools/convert_states.py`.
8
+ Only state abbreviation/name and geometry are retained. These are generalized
9
+ cartographic outlines for visualization, not legal or survey boundaries.
10
+ Coordinates are NAD83 geographic coordinates, displayed on the web basemap as
11
+ longitude/latitude; datum differences are negligible at this display scale.
12
+ U.S. federal government geographic data; attribution: U.S. Census Bureau.