krishnadhulipalla commited on
Commit
71c1c9d
·
1 Parent(s): 484131e

pulsemap 1.2

Browse files
Files changed (36) hide show
  1. .gitattributes +2 -0
  2. backend/app/agents/graph.py +12 -11
  3. backend/app/census/cb_2024_us_tract_500k.cpg +3 -0
  4. backend/app/census/cb_2024_us_tract_500k.dbf +3 -0
  5. backend/app/census/cb_2024_us_tract_500k.prj +3 -0
  6. backend/app/census/cb_2024_us_tract_500k.shp +3 -0
  7. backend/app/census/cb_2024_us_tract_500k.shp.ea.iso.xml +399 -0
  8. backend/app/census/cb_2024_us_tract_500k.shp.iso.xml +745 -0
  9. backend/app/census/cb_2024_us_tract_500k.shx +3 -0
  10. backend/app/config/settings.py +24 -45
  11. backend/app/data/store.py +41 -18
  12. backend/app/main.py +12 -3
  13. backend/app/routers/config.py +1 -1
  14. backend/app/routers/feeds.py +1 -1
  15. backend/app/routers/geo.py +14 -0
  16. backend/app/routers/reactions.py +22 -0
  17. backend/app/services/feeds.py +2 -0
  18. backend/app/services/reactions.py +56 -0
  19. backend/app/services/tracts.py +62 -0
  20. web/src/App.tsx +168 -2
  21. web/src/components/map/MapCanvas.tsx +27 -70
  22. web/src/components/map/controls/LegendControl.tsx +43 -0
  23. web/src/components/map/controls/MyLocationControl.tsx +25 -4
  24. web/src/components/map/overlays/TractsLayer.tsx +191 -0
  25. web/src/components/modals/NearbyAlertModal.tsx +235 -0
  26. web/src/components/sidebar/NearbyAlertsPanel.tsx +234 -0
  27. web/src/components/sidebar/SelectedLocationCard.tsx +12 -0
  28. web/src/components/sidebar/UpdatesPanel.tsx +33 -3
  29. web/src/hooks/useFeeds.ts +0 -3
  30. web/src/hooks/useNearbyQueue.ts +153 -0
  31. web/src/hooks/useProximityAlerts.ts +82 -0
  32. web/src/lib/constants.ts +5 -7
  33. web/src/lib/geo.ts +26 -0
  34. web/src/lib/severity.ts +92 -0
  35. web/src/lib/types.ts +9 -0
  36. web/src/style.css +99 -0
.gitattributes CHANGED
@@ -34,6 +34,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  *.pdf filter=lfs diff=lfs merge=lfs -text
 
 
37
  *.shp filter=lfs diff=lfs merge=lfs -text
38
  *.dbf filter=lfs diff=lfs merge=lfs -text
39
  *.shx filter=lfs diff=lfs merge=lfs -text
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  *.pdf filter=lfs diff=lfs merge=lfs -text
37
+ backend/app/census/cb_2024_us_tract_500k.shp filter=lfs diff=lfs merge=lfs -text
38
+ backend/app/census/cb_2024_us_tract_500k.dbf filter=lfs diff=lfs merge=lfs -text
39
  *.shp filter=lfs diff=lfs merge=lfs -text
40
  *.dbf filter=lfs diff=lfs merge=lfs -text
41
  *.shx filter=lfs diff=lfs merge=lfs -text
backend/app/agents/graph.py CHANGED
@@ -24,19 +24,20 @@ You help people add reports and discover what’s happening around them.
24
 
25
  ### How to answer
26
  - Speak like a helpful neighbor, not a robot.
27
- - Use plain text only. No **bold**, no numbered lists, no markdown tables.
28
- - After a tool call, start with a quick recap then list items newest first using hyphen bullets.
29
- *“I checked within 25 miles of your location and found 3 updates.”*
30
- For each item, one line like:
31
- - 🔫 Gunshot Severity: High; Confidence: 0.9; Time: 2h ago; Source: User; Photo: yes
32
- - If nothing found:
33
- - “I didn’t find anything within 25 miles in the last 48 hours. Want me to widen the search?”
 
34
 
35
  ### Safety
36
- - Keep a supportive tone. Do not dramatize.
37
- - End with situational advice when it makes sense (e.g. “Avoid driving through floodwater”).
38
- - Only mention calling 911 if the report itself clearly describes an urgent danger.
39
- - Never invent reports — summarize only what tools/feed data provide.
40
  """
41
 
42
  # Long-lived sessions DB (same filename as before)
 
24
 
25
  ### How to answer
26
  - Speak like a helpful neighbor, not a robot.
27
+ - Use plain text only. No bold, no numbered lists, no markdown tables.
28
+ - After a tool call, give a short summary first, then share the findings newest first.
29
+ Example: “I looked within 25 miles of your spot and found 3 updates.”
30
+ - Each report should be a single, natural sentence with key info in a readable flow:
31
+ Gunshot reported near Main St about 2 hours ago. Severity high, confidence 0.9. Photo attached.”
32
+ • “Flooding on Oak Avenue seen 5 hours ago. Severity medium, user-submitted without photo.”
33
+ - If nothing found, say:
34
+ • “I didn’t find any reports in the last 48 hours within 25 miles. Would you like me to widen the search?”
35
 
36
  ### Safety
37
+ - Keep the tone calm and supportive.
38
+ - End with a short situational tip if it makes sense (e.g. “Try to avoid low-lying roads if rain continues”).
39
+ - Mention calling 911 only if the report clearly describes an immediate life-threatening danger.
40
+ - Never invent reports — only describe what the tools or feeds provide.
41
  """
42
 
43
  # Long-lived sessions DB (same filename as before)
backend/app/census/cb_2024_us_tract_500k.cpg ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3ad3031f5503a4404af825262ee8232cc04d4ea6683d42c5dd0a2f2a27ac9824
3
+ size 5
backend/app/census/cb_2024_us_tract_500k.dbf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9c3ec5329cf3546c3f15541cabcf10b58e6a19d6381e6077d577ff402b1c9157
3
+ size 40462850
backend/app/census/cb_2024_us_tract_500k.prj ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0b9041e921d9ebb43247d314608fe9e38a0b008ee793067fc1806199ea1fb9dd
3
+ size 165
backend/app/census/cb_2024_us_tract_500k.shp ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:90118ab192cf818ef8d19ffbe2bf6da9e8f857e63c369eb07ac81649c8dcd2a3
3
+ size 82399764
backend/app/census/cb_2024_us_tract_500k.shp.ea.iso.xml ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <gfc:FC_FeatureCatalogue xmlns:xlink="http://www.w3.org/1999/xlink"
3
+ xmlns:gmd="http://www.isotc211.org/2005/gmd"
4
+ xmlns:gco="http://www.isotc211.org/2005/gco"
5
+ xmlns:gml="http://www.opengis.net/gml/3.2"
6
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
7
+ xmlns:gmi="http://www.isotc211.org/2005/gmi"
8
+ xmlns:srv="http://www.isotc211.org/2005/srv"
9
+ xmlns:gmx="http://www.isotc211.org/2005/gmx"
10
+ xmlns:gfc="http://www.isotc211.org/2005/gfc">
11
+ <gmx:name>
12
+ <gco:CharacterString>Feature Catalog for the 2024 Census Tract for United States Cartographic Boundary File</gco:CharacterString>
13
+ </gmx:name>
14
+ <gmx:scope>
15
+ <gco:CharacterString>Census Tracts</gco:CharacterString>
16
+ </gmx:scope>
17
+ <gmx:versionNumber>
18
+ <gco:CharacterString>2025-05</gco:CharacterString>
19
+ </gmx:versionNumber>
20
+ <gmx:versionDate>
21
+ <gco:Date>2025-05</gco:Date>
22
+ </gmx:versionDate>
23
+ <gmx:language>
24
+ <gco:CharacterString>eng; USA</gco:CharacterString>
25
+ </gmx:language>
26
+ <gmx:characterSet>
27
+ <gmd:MD_CharacterSetCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_CharacterSetCode"
28
+ codeListValue="utf8">utf8</gmd:MD_CharacterSetCode>
29
+ </gmx:characterSet>
30
+ <gfc:producer>
31
+ <gmd:CI_ResponsibleParty>
32
+ <gmd:organisationName>
33
+ <gco:CharacterString>U.S. Department of Commerce, U.S. Census Bureau, Geography Division</gco:CharacterString>
34
+ </gmd:organisationName>
35
+ <gmd:contactInfo>
36
+ <gmd:CI_Contact>
37
+ <gmd:phone>
38
+ <gmd:CI_Telephone>
39
+ <gmd:voice>
40
+ <gco:CharacterString>301-763-1128</gco:CharacterString>
41
+ </gmd:voice>
42
+ <gmd:facsimile>
43
+ <gco:CharacterString>301-763-4710</gco:CharacterString>
44
+ </gmd:facsimile>
45
+ </gmd:CI_Telephone>
46
+ </gmd:phone>
47
+ <gmd:address>
48
+ <gmd:CI_Address>
49
+ <gmd:deliveryPoint>
50
+ <gco:CharacterString>4600 Silver Hill Road, Stop 7400</gco:CharacterString>
51
+ </gmd:deliveryPoint>
52
+ <gmd:city>
53
+ <gco:CharacterString>Washington</gco:CharacterString>
54
+ </gmd:city>
55
+ <gmd:administrativeArea>
56
+ <gco:CharacterString>DC</gco:CharacterString>
57
+ </gmd:administrativeArea>
58
+ <gmd:postalCode>
59
+ <gco:CharacterString>20233-7400</gco:CharacterString>
60
+ </gmd:postalCode>
61
+ <gmd:country>
62
+ <gco:CharacterString>United States</gco:CharacterString>
63
+ </gmd:country>
64
+ <gmd:electronicMailAddress>
65
+ <gco:CharacterString>geo.geography@census.gov</gco:CharacterString>
66
+ </gmd:electronicMailAddress>
67
+ </gmd:CI_Address>
68
+ </gmd:address>
69
+ </gmd:CI_Contact>
70
+ </gmd:contactInfo>
71
+ <gmd:role>
72
+ <gmd:CI_RoleCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_RoleCode"
73
+ codeListValue="pointOfContact">pointOfContact</gmd:CI_RoleCode>
74
+ </gmd:role>
75
+ </gmd:CI_ResponsibleParty>
76
+ </gfc:producer>
77
+ <gfc:featureType>
78
+ <gfc:FC_FeatureType>
79
+ <gfc:typeName>
80
+ <gco:LocalName>cb_2024_us_tract_500k.shp</gco:LocalName>
81
+ </gfc:typeName>
82
+ <gfc:definition>
83
+ <gco:CharacterString>Census Tract (national)</gco:CharacterString>
84
+ </gfc:definition>
85
+ <gfc:isAbstract>
86
+ <gco:Boolean>false</gco:Boolean>
87
+ </gfc:isAbstract>
88
+ <gfc:featureCatalogue uuidref="cb_2024_us_tract_500k.shp.ea.iso.xml"/>
89
+ <gfc:carrierOfCharacteristics>
90
+ <gfc:FC_FeatureAttribute>
91
+ <gfc:memberName>
92
+ <gco:LocalName>STATEFP</gco:LocalName>
93
+ </gfc:memberName>
94
+ <gfc:definition>
95
+ <gco:CharacterString>Current state Federal Information Processing Series (FIPS) code</gco:CharacterString>
96
+ </gfc:definition>
97
+ <gfc:cardinality gco:nilReason="unknown"/>
98
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
99
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
100
+ <gfc:listedValue>
101
+ <gfc:FC_ListedValue>
102
+ <gfc:label>
103
+ <gco:CharacterString>National Standard Codes (ANSI INCITS 38-2009), Federal Information Processing Series (FIPS) - States/State Equivalents</gco:CharacterString>
104
+ </gfc:label>
105
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
106
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
107
+ </gfc:FC_ListedValue>
108
+ </gfc:listedValue>
109
+ </gfc:FC_FeatureAttribute>
110
+ </gfc:carrierOfCharacteristics>
111
+ <gfc:carrierOfCharacteristics>
112
+ <gfc:FC_FeatureAttribute>
113
+ <gfc:memberName>
114
+ <gco:LocalName>COUNTYFP</gco:LocalName>
115
+ </gfc:memberName>
116
+ <gfc:definition>
117
+ <gco:CharacterString>Current county Federal Information Processing Series (FIPS) code</gco:CharacterString>
118
+ </gfc:definition>
119
+ <gfc:cardinality gco:nilReason="unknown"/>
120
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
121
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
122
+ <gfc:listedValue>
123
+ <gfc:FC_ListedValue>
124
+ <gfc:label>
125
+ <gco:CharacterString>National Standard Codes (ANSI INCITS 31-2009), Federal Information Processing Series (FIPS) - Counties/County Equivalents</gco:CharacterString>
126
+ </gfc:label>
127
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
128
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
129
+ </gfc:FC_ListedValue>
130
+ </gfc:listedValue>
131
+ </gfc:FC_FeatureAttribute>
132
+ </gfc:carrierOfCharacteristics>
133
+ <gfc:carrierOfCharacteristics>
134
+ <gfc:FC_FeatureAttribute>
135
+ <gfc:memberName>
136
+ <gco:LocalName>TRACTCE</gco:LocalName>
137
+ </gfc:memberName>
138
+ <gfc:definition>
139
+ <gco:CharacterString>Current census tract code</gco:CharacterString>
140
+ </gfc:definition>
141
+ <gfc:cardinality gco:nilReason="unknown"/>
142
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
143
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
144
+ <gfc:listedValue>
145
+ <gfc:FC_ListedValue>
146
+ <gfc:label>
147
+ <gco:CharacterString>000000</gco:CharacterString>
148
+ </gfc:label>
149
+ <gfc:definition>
150
+ <gco:CharacterString>Water tract in some coastal and Great Lakes water and territorial sea</gco:CharacterString>
151
+ </gfc:definition>
152
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
153
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
154
+ </gfc:FC_ListedValue>
155
+ </gfc:listedValue>
156
+ <gfc:listedValue>
157
+ <gfc:FC_ListedValue>
158
+ <gfc:label>
159
+ <gco:CharacterString>000100 to 998999</gco:CharacterString>
160
+ </gfc:label>
161
+ <gfc:definition>
162
+ <gco:CharacterString>Census tract number</gco:CharacterString>
163
+ </gfc:definition>
164
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
165
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
166
+ </gfc:FC_ListedValue>
167
+ </gfc:listedValue>
168
+ </gfc:FC_FeatureAttribute>
169
+ </gfc:carrierOfCharacteristics>
170
+ <gfc:carrierOfCharacteristics>
171
+ <gfc:FC_FeatureAttribute>
172
+ <gfc:memberName>
173
+ <gco:LocalName>GEOIDFQ</gco:LocalName>
174
+ </gfc:memberName>
175
+ <gfc:definition>
176
+ <gco:CharacterString>American FactFinder summary level code + geovariant code + '00US' + GEOID</gco:CharacterString>
177
+ </gfc:definition>
178
+ <gfc:cardinality gco:nilReason="unknown"/>
179
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
180
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
181
+ <gfc:listedValue>
182
+ <gfc:FC_ListedValue>
183
+ <gfc:label>
184
+ <gco:CharacterString>Fully Qualified GEOID</gco:CharacterString>
185
+ </gfc:label>
186
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
187
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
188
+ </gfc:FC_ListedValue>
189
+ </gfc:listedValue>
190
+ </gfc:FC_FeatureAttribute>
191
+ </gfc:carrierOfCharacteristics>
192
+ <gfc:carrierOfCharacteristics>
193
+ <gfc:FC_FeatureAttribute>
194
+ <gfc:memberName>
195
+ <gco:LocalName>GEOID</gco:LocalName>
196
+ </gfc:memberName>
197
+ <gfc:definition>
198
+ <gco:CharacterString>Census tract identifier; a concatenation of current state Federal Information Processing Series (FIPS) code, county FIPS code, and census tract code</gco:CharacterString>
199
+ </gfc:definition>
200
+ <gfc:cardinality gco:nilReason="unknown"/>
201
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
202
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
203
+ <gfc:listedValue>
204
+ <gfc:FC_ListedValue>
205
+ <gfc:label gco:nilReason="inapplicable"/>
206
+ <gfc:definition>
207
+ <gco:CharacterString>The GEOID attribute is a concatenation of the state FIPS code, followed by the county FIPS code, followed by the census tract code. No spaces are allowed between the two codes. The state FIPS code is taken from "National Standard Codes (ANSI INCITS 38-2009), Federal Information Processing Series (FIPS) - States". The county FIPS code is taken from "National Standard Codes (ANSI INCITS 31-2009), Federal Information Processing Series (FIPS) - Counties/County Equivalents". The census tract code is taken from the "TRACTCE" attribute.</gco:CharacterString>
208
+ </gfc:definition>
209
+ </gfc:FC_ListedValue>
210
+ </gfc:listedValue>
211
+ </gfc:FC_FeatureAttribute>
212
+ </gfc:carrierOfCharacteristics>
213
+ <gfc:carrierOfCharacteristics>
214
+ <gfc:FC_FeatureAttribute>
215
+ <gfc:memberName>
216
+ <gco:LocalName>NAME</gco:LocalName>
217
+ </gfc:memberName>
218
+ <gfc:definition>
219
+ <gco:CharacterString>Current census tract name, this is the census tract code converted to an integer or integer plus two-digit decimal if the last two characters of the code are not both zeros.</gco:CharacterString>
220
+ </gfc:definition>
221
+ <gfc:cardinality gco:nilReason="unknown"/>
222
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
223
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
224
+ <gfc:listedValue>
225
+ <gfc:FC_ListedValue>
226
+ <gfc:label gco:nilReason="inapplicable"/>
227
+ <gfc:definition>
228
+ <gco:CharacterString>Values for this attribute are composed of a set of census tract names. As such, they do not exist in a known, predefined set.</gco:CharacterString>
229
+ </gfc:definition>
230
+ </gfc:FC_ListedValue>
231
+ </gfc:listedValue>
232
+ </gfc:FC_FeatureAttribute>
233
+ </gfc:carrierOfCharacteristics>
234
+ <gfc:carrierOfCharacteristics>
235
+ <gfc:FC_FeatureAttribute>
236
+ <gfc:memberName>
237
+ <gco:LocalName>NAMELSAD</gco:LocalName>
238
+ </gfc:memberName>
239
+ <gfc:definition>
240
+ <gco:CharacterString>Current name and the translated legal/statistical area description for census tract</gco:CharacterString>
241
+ </gfc:definition>
242
+ <gfc:cardinality gco:nilReason="unknown"/>
243
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
244
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
245
+ <gfc:listedValue>
246
+ <gfc:FC_ListedValue>
247
+ <gfc:label gco:nilReason="inapplicable"/>
248
+ <gfc:definition>
249
+ <gco:CharacterString>Refer to the name in the TRACTCE field and the translated legal/statistical area description code for census tracts</gco:CharacterString>
250
+ </gfc:definition>
251
+ </gfc:FC_ListedValue>
252
+ </gfc:listedValue>
253
+ </gfc:FC_FeatureAttribute>
254
+ </gfc:carrierOfCharacteristics>
255
+ <gfc:carrierOfCharacteristics>
256
+ <gfc:FC_FeatureAttribute>
257
+ <gfc:memberName>
258
+ <gco:LocalName>STUSPS</gco:LocalName>
259
+ </gfc:memberName>
260
+ <gfc:definition>
261
+ <gco:CharacterString>Current United States Postal Service state abbreviation</gco:CharacterString>
262
+ </gfc:definition>
263
+ <gfc:cardinality gco:nilReason="unknown"/>
264
+ <gfc:definitionReference xlink:title="U.S. Postal Service"
265
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/2de06071-f4d0-49b7-bd93-ba7a38e5d911 "/>
266
+ <gfc:listedValue>
267
+ <gfc:FC_ListedValue>
268
+ <gfc:label>
269
+ <gco:CharacterString>Publication 28 - Postal Addressing Standards</gco:CharacterString>
270
+ </gfc:label>
271
+ <gfc:definitionReference xlink:title="U.S. Postal Service"
272
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/2de06071-f4d0-49b7-bd93-ba7a38e5d911 "/>
273
+ </gfc:FC_ListedValue>
274
+ </gfc:listedValue>
275
+ </gfc:FC_FeatureAttribute>
276
+ </gfc:carrierOfCharacteristics>
277
+ <gfc:carrierOfCharacteristics>
278
+ <gfc:FC_FeatureAttribute>
279
+ <gfc:memberName>
280
+ <gco:LocalName>NAMELSADCO</gco:LocalName>
281
+ </gfc:memberName>
282
+ <gfc:definition>
283
+ <gco:CharacterString>Current name and the translated legal/statistical area description for county</gco:CharacterString>
284
+ </gfc:definition>
285
+ <gfc:cardinality gco:nilReason="unknown"/>
286
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
287
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
288
+ <gfc:listedValue>
289
+ <gfc:FC_ListedValue>
290
+ <gfc:label gco:nilReason="inapplicable"/>
291
+ <gfc:definition>
292
+ <gco:CharacterString>Current name and the translated legal/statistical area description for county</gco:CharacterString>
293
+ </gfc:definition>
294
+ </gfc:FC_ListedValue>
295
+ </gfc:listedValue>
296
+ </gfc:FC_FeatureAttribute>
297
+ </gfc:carrierOfCharacteristics>
298
+ <gfc:carrierOfCharacteristics>
299
+ <gfc:FC_FeatureAttribute>
300
+ <gfc:memberName>
301
+ <gco:LocalName>STATE_NAME</gco:LocalName>
302
+ </gfc:memberName>
303
+ <gfc:definition>
304
+ <gco:CharacterString>Current State name</gco:CharacterString>
305
+ </gfc:definition>
306
+ <gfc:cardinality gco:nilReason="unknown"/>
307
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
308
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
309
+ <gfc:listedValue>
310
+ <gfc:FC_ListedValue>
311
+ <gfc:label>
312
+ <gco:CharacterString>National Standard Codes (ANSI INCITS 38-2009), Federal Information Processing Series (FIPS) - States/State Equivalents</gco:CharacterString>
313
+ </gfc:label>
314
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
315
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
316
+ </gfc:FC_ListedValue>
317
+ </gfc:listedValue>
318
+ </gfc:FC_FeatureAttribute>
319
+ </gfc:carrierOfCharacteristics>
320
+ <gfc:carrierOfCharacteristics>
321
+ <gfc:FC_FeatureAttribute>
322
+ <gfc:memberName>
323
+ <gco:LocalName>LSAD</gco:LocalName>
324
+ </gfc:memberName>
325
+ <gfc:definition>
326
+ <gco:CharacterString>Current legal/statistical area description code for Census tract</gco:CharacterString>
327
+ </gfc:definition>
328
+ <gfc:cardinality gco:nilReason="unknown"/>
329
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
330
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
331
+ <gfc:listedValue>
332
+ <gfc:FC_ListedValue>
333
+ <gfc:label>
334
+ <gco:CharacterString>CT</gco:CharacterString>
335
+ </gfc:label>
336
+ <gfc:definition>
337
+ <gco:CharacterString>Census Tract (prefix)</gco:CharacterString>
338
+ </gfc:definition>
339
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
340
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
341
+ </gfc:FC_ListedValue>
342
+ </gfc:listedValue>
343
+ </gfc:FC_FeatureAttribute>
344
+ </gfc:carrierOfCharacteristics>
345
+ <gfc:carrierOfCharacteristics>
346
+ <gfc:FC_FeatureAttribute>
347
+ <gfc:memberName>
348
+ <gco:LocalName>ALAND</gco:LocalName>
349
+ </gfc:memberName>
350
+ <gfc:definition>
351
+ <gco:CharacterString>Current land area (square meters)</gco:CharacterString>
352
+ </gfc:definition>
353
+ <gfc:cardinality>
354
+ <gco:Multiplicity>
355
+ <gco:range>
356
+ <gco:MultiplicityRange>
357
+ <gco:lower>
358
+ <gco:Integer>0</gco:Integer>
359
+ </gco:lower>
360
+ <gco:upper>
361
+ <gco:UnlimitedInteger>9,999,999,999,999</gco:UnlimitedInteger>
362
+ </gco:upper>
363
+ </gco:MultiplicityRange>
364
+ </gco:range>
365
+ </gco:Multiplicity>
366
+ </gfc:cardinality>
367
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
368
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
369
+ </gfc:FC_FeatureAttribute>
370
+ </gfc:carrierOfCharacteristics>
371
+ <gfc:carrierOfCharacteristics>
372
+ <gfc:FC_FeatureAttribute>
373
+ <gfc:memberName>
374
+ <gco:LocalName>AWATER</gco:LocalName>
375
+ </gfc:memberName>
376
+ <gfc:definition>
377
+ <gco:CharacterString>Current water area (square meters)</gco:CharacterString>
378
+ </gfc:definition>
379
+ <gfc:cardinality>
380
+ <gco:Multiplicity>
381
+ <gco:range>
382
+ <gco:MultiplicityRange>
383
+ <gco:lower>
384
+ <gco:Integer>0</gco:Integer>
385
+ </gco:lower>
386
+ <gco:upper>
387
+ <gco:UnlimitedInteger>9,999,999,999,999</gco:UnlimitedInteger>
388
+ </gco:upper>
389
+ </gco:MultiplicityRange>
390
+ </gco:range>
391
+ </gco:Multiplicity>
392
+ </gfc:cardinality>
393
+ <gfc:definitionReference xlink:title="U.S Census Bureau"
394
+ xlink:href="http://www.ngdc.noaa.gov/docucomp/eb139e38-ee29-4d59-b157-5e874d4420c4"/>
395
+ </gfc:FC_FeatureAttribute>
396
+ </gfc:carrierOfCharacteristics>
397
+ </gfc:FC_FeatureType>
398
+ </gfc:featureType>
399
+ </gfc:FC_FeatureCatalogue>
backend/app/census/cb_2024_us_tract_500k.shp.iso.xml ADDED
@@ -0,0 +1,745 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <gmi:MI_Metadata xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3
+ xmlns:gmd="http://www.isotc211.org/2005/gmd"
4
+ xmlns:gco="http://www.isotc211.org/2005/gco"
5
+ xmlns:gml="http://www.opengis.net/gml/3.2"
6
+ xmlns:gmi="http://www.isotc211.org/2005/gmi"
7
+ xmlns:xlink="http://www.w3.org/1999/xlink"
8
+ xmlns:srv="http://www.isotc211.org/2005/srv"
9
+ xsi:schemaLocation="http://www.isotc211.org/2005/gmi https://data.noaa.gov/resources/iso19139/schema.xsd">
10
+ <gmd:fileIdentifier>
11
+ <gco:CharacterString>cb_2024_us_tract_500k.shp.iso.xml</gco:CharacterString>
12
+ </gmd:fileIdentifier>
13
+ <gmd:language>
14
+ <gco:CharacterString>eng; USA</gco:CharacterString>
15
+ </gmd:language>
16
+ <gmd:characterSet>
17
+ <gmd:MD_CharacterSetCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_CharacterSetCode"
18
+ codeListValue="utf8">utf8</gmd:MD_CharacterSetCode>
19
+ </gmd:characterSet>
20
+ <gmd:hierarchyLevel>
21
+ <gmd:MD_ScopeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_ScopeCode"
22
+ codeListValue="dataset">dataset</gmd:MD_ScopeCode>
23
+ </gmd:hierarchyLevel>
24
+ <gmd:contact>
25
+ <gmd:CI_ResponsibleParty>
26
+ <gmd:organisationName>
27
+ <gco:CharacterString>U.S. Department of Commerce, U.S. Census Bureau, Geography Division</gco:CharacterString>
28
+ </gmd:organisationName>
29
+ <gmd:contactInfo>
30
+ <gmd:CI_Contact>
31
+ <gmd:phone>
32
+ <gmd:CI_Telephone>
33
+ <gmd:voice>
34
+ <gco:CharacterString>301-763-1128</gco:CharacterString>
35
+ </gmd:voice>
36
+ <gmd:facsimile>
37
+ <gco:CharacterString>301-763-4710</gco:CharacterString>
38
+ </gmd:facsimile>
39
+ </gmd:CI_Telephone>
40
+ </gmd:phone>
41
+ <gmd:address>
42
+ <gmd:CI_Address>
43
+ <gmd:deliveryPoint>
44
+ <gco:CharacterString>4600 Silver Hill Road, Stop 7400</gco:CharacterString>
45
+ </gmd:deliveryPoint>
46
+ <gmd:city>
47
+ <gco:CharacterString>Washington</gco:CharacterString>
48
+ </gmd:city>
49
+ <gmd:administrativeArea>
50
+ <gco:CharacterString>DC</gco:CharacterString>
51
+ </gmd:administrativeArea>
52
+ <gmd:postalCode>
53
+ <gco:CharacterString>20233-7400</gco:CharacterString>
54
+ </gmd:postalCode>
55
+ <gmd:country>
56
+ <gco:CharacterString>United States</gco:CharacterString>
57
+ </gmd:country>
58
+ <gmd:electronicMailAddress>
59
+ <gco:CharacterString>geo.geography@census.gov</gco:CharacterString>
60
+ </gmd:electronicMailAddress>
61
+ </gmd:CI_Address>
62
+ </gmd:address>
63
+ </gmd:CI_Contact>
64
+ </gmd:contactInfo>
65
+ <gmd:role>
66
+ <gmd:CI_RoleCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_RoleCode"
67
+ codeListValue="pointOfContact">pointOfContact</gmd:CI_RoleCode>
68
+ </gmd:role>
69
+ </gmd:CI_ResponsibleParty>
70
+ </gmd:contact>
71
+ <gmd:dateStamp>
72
+ <gco:Date>2025-05</gco:Date>
73
+ </gmd:dateStamp>
74
+ <gmd:metadataStandardName>
75
+ <gco:CharacterString>ISO 19115-2 Geographic Information - Metadata - Part 2: Extensions for Imagery and Gridded Data</gco:CharacterString>
76
+ </gmd:metadataStandardName>
77
+ <gmd:metadataStandardVersion>
78
+ <gco:CharacterString>ISO 19115-2:2009(E)</gco:CharacterString>
79
+ </gmd:metadataStandardVersion>
80
+ <gmd:dataSetURI>
81
+ <gco:CharacterString>https://meta.geo.census.gov/data/existing/decennial/GEO/CPMB/boundary/2024/19115/shp/cb_2024_us_tract_500k.shp.iso.xml</gco:CharacterString>
82
+ </gmd:dataSetURI>
83
+ <gmd:spatialRepresentationInfo>
84
+ <gmd:MD_VectorSpatialRepresentation>
85
+ <gmd:geometricObjects>
86
+ <gmd:MD_GeometricObjects>
87
+ <gmd:geometricObjectType>
88
+ <gmd:MD_GeometricObjectTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_GeometricObjectTypeCode"
89
+ codeListValue="complex">complex</gmd:MD_GeometricObjectTypeCode>
90
+ </gmd:geometricObjectType>
91
+ <gmd:geometricObjectCount>
92
+ <gco:Integer>85184</gco:Integer>
93
+ </gmd:geometricObjectCount>
94
+ </gmd:MD_GeometricObjects>
95
+ </gmd:geometricObjects>
96
+ </gmd:MD_VectorSpatialRepresentation>
97
+ </gmd:spatialRepresentationInfo>
98
+ <gmd:referenceSystemInfo>
99
+ <gmd:MD_ReferenceSystem uuid="65f8b220-95ed-11e0-aa80-0800200c9a66">
100
+ <gmd:referenceSystemIdentifier>
101
+ <gmd:RS_Identifier>
102
+ <gmd:authority>
103
+ <gmd:CI_Citation>
104
+ <gmd:title>
105
+ <gco:CharacterString>North American Datum of 1983</gco:CharacterString>
106
+ </gmd:title>
107
+ <gmd:alternateTitle>
108
+ <gco:CharacterString>NAD83</gco:CharacterString>
109
+ </gmd:alternateTitle>
110
+ <gmd:date>
111
+ <gmd:CI_Date>
112
+ <gmd:date>
113
+ <gco:Date>2007-01-19</gco:Date>
114
+ </gmd:date>
115
+ <gmd:dateType>
116
+ <gmd:CI_DateTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_DateTypeCode"
117
+ codeListValue="revision">revision</gmd:CI_DateTypeCode>
118
+ </gmd:dateType>
119
+ </gmd:CI_Date>
120
+ </gmd:date>
121
+ <gmd:citedResponsibleParty>
122
+ <gmd:CI_ResponsibleParty>
123
+ <gmd:organisationName gco:nilReason="withheld"/>
124
+ <gmd:contactInfo>
125
+ <gmd:CI_Contact>
126
+ <gmd:onlineResource>
127
+ <gmd:CI_OnlineResource>
128
+ <gmd:linkage>
129
+ <gmd:URL>https://spatialreference.org/ref/epsg/4269/gml/</gmd:URL>
130
+ </gmd:linkage>
131
+ <gmd:name>
132
+ <gco:CharacterString>NAD83</gco:CharacterString>
133
+ </gmd:name>
134
+ <gmd:description>
135
+ <gco:CharacterString>Link to Geographic Markup Language (GML) description of reference system.</gco:CharacterString>
136
+ </gmd:description>
137
+ <gmd:function>
138
+ <gmd:CI_OnLineFunctionCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_OnLineFunctionCode"
139
+ codeListValue="information">information</gmd:CI_OnLineFunctionCode>
140
+ </gmd:function>
141
+ </gmd:CI_OnlineResource>
142
+ </gmd:onlineResource>
143
+ </gmd:CI_Contact>
144
+ </gmd:contactInfo>
145
+ <gmd:role>
146
+ <gmd:CI_RoleCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_RoleCode"
147
+ codeListValue="resourceProvider">resourceProvider</gmd:CI_RoleCode>
148
+ </gmd:role>
149
+ </gmd:CI_ResponsibleParty>
150
+ </gmd:citedResponsibleParty>
151
+ </gmd:CI_Citation>
152
+ </gmd:authority>
153
+ <gmd:code>
154
+ <gco:CharacterString>urn:ogc:def:crs:EPSG::4269</gco:CharacterString>
155
+ </gmd:code>
156
+ </gmd:RS_Identifier>
157
+ </gmd:referenceSystemIdentifier>
158
+ </gmd:MD_ReferenceSystem>
159
+ </gmd:referenceSystemInfo>
160
+ <gmd:identificationInfo>
161
+ <gmd:MD_DataIdentification>
162
+ <gmd:citation>
163
+ <gmd:CI_Citation>
164
+ <gmd:title>
165
+ <gco:CharacterString>2024 Cartographic Boundary File (SHP), Census Tract for United States, 1:500,000</gco:CharacterString>
166
+ </gmd:title>
167
+ <gmd:date>
168
+ <gmd:CI_Date>
169
+ <gmd:date>
170
+ <gco:Date>2025-05</gco:Date>
171
+ </gmd:date>
172
+ <gmd:dateType>
173
+ <gmd:CI_DateTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_DateTypeCode"
174
+ codeListValue="publication">publication</gmd:CI_DateTypeCode>
175
+ </gmd:dateType>
176
+ </gmd:CI_Date>
177
+ </gmd:date>
178
+ <gmd:date>
179
+ <gmd:CI_Date>
180
+ <gmd:date>
181
+ <gco:Date>2025-05</gco:Date>
182
+ </gmd:date>
183
+ <gmd:dateType>
184
+ <gmd:CI_DateTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_DateTypeCode"
185
+ codeListValue="creation">creation</gmd:CI_DateTypeCode>
186
+ </gmd:dateType>
187
+ </gmd:CI_Date>
188
+ </gmd:date>
189
+ <gmd:date>
190
+ <gmd:CI_Date>
191
+ <gmd:date>
192
+ <gco:Date>2025-05</gco:Date>
193
+ </gmd:date>
194
+ <gmd:dateType>
195
+ <gmd:CI_DateTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_DateTypeCode"
196
+ codeListValue="lastUpdate">lastUpdate</gmd:CI_DateTypeCode>
197
+ </gmd:dateType>
198
+ </gmd:CI_Date>
199
+ </gmd:date>
200
+ <gmd:identifier>
201
+ <gmd:MD_Identifier>
202
+ <gmd:code>
203
+ <gco:CharacterString>cb_2024_us_tract_500k</gco:CharacterString>
204
+ </gmd:code>
205
+ </gmd:MD_Identifier>
206
+ </gmd:identifier>
207
+ <gmd:citedResponsibleParty>
208
+ <gmd:CI_ResponsibleParty>
209
+ <gmd:organisationName>
210
+ <gco:CharacterString>U.S. Department of Commerce, U.S. Census Bureau, Geography Division</gco:CharacterString>
211
+ </gmd:organisationName>
212
+ <gmd:contactInfo>
213
+ <gmd:CI_Contact>
214
+ <gmd:phone>
215
+ <gmd:CI_Telephone>
216
+ <gmd:voice>
217
+ <gco:CharacterString>301-763-1128</gco:CharacterString>
218
+ </gmd:voice>
219
+ <gmd:facsimile>
220
+ <gco:CharacterString>301-763-4710</gco:CharacterString>
221
+ </gmd:facsimile>
222
+ </gmd:CI_Telephone>
223
+ </gmd:phone>
224
+ <gmd:address>
225
+ <gmd:CI_Address>
226
+ <gmd:deliveryPoint>
227
+ <gco:CharacterString>4600 Silver Hill Road, Stop 7400</gco:CharacterString>
228
+ </gmd:deliveryPoint>
229
+ <gmd:city>
230
+ <gco:CharacterString>Washington</gco:CharacterString>
231
+ </gmd:city>
232
+ <gmd:administrativeArea>
233
+ <gco:CharacterString>DC</gco:CharacterString>
234
+ </gmd:administrativeArea>
235
+ <gmd:postalCode>
236
+ <gco:CharacterString>20233-7400</gco:CharacterString>
237
+ </gmd:postalCode>
238
+ <gmd:country>
239
+ <gco:CharacterString>United States</gco:CharacterString>
240
+ </gmd:country>
241
+ <gmd:electronicMailAddress>
242
+ <gco:CharacterString>geo.geography@census.gov</gco:CharacterString>
243
+ </gmd:electronicMailAddress>
244
+ </gmd:CI_Address>
245
+ </gmd:address>
246
+ </gmd:CI_Contact>
247
+ </gmd:contactInfo>
248
+ <gmd:role>
249
+ <gmd:CI_RoleCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_RoleCode"
250
+ codeListValue="pointOfContact">pointOfContact</gmd:CI_RoleCode>
251
+ </gmd:role>
252
+ </gmd:CI_ResponsibleParty>
253
+ </gmd:citedResponsibleParty>
254
+ <gmd:presentationForm>
255
+ <gmd:CI_PresentationFormCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_PresentationFormCode"
256
+ codeListValue="mapDigital">mapDigital</gmd:CI_PresentationFormCode>
257
+ </gmd:presentationForm>
258
+ </gmd:CI_Citation>
259
+ </gmd:citation>
260
+ <gmd:abstract>
261
+ <gco:CharacterString>The 2024 cartographic boundary shapefiles are simplified representations of selected geographic areas from the U.S. Census Bureau's Master Address File / Topologically Integrated Geographic Encoding and Referencing (MAF/TIGER) Database (MTDB). These boundary files are specifically designed for small-scale thematic mapping. When possible, generalization is performed with the intent to maintain the hierarchical relationships among geographies and to maintain the alignment of geographies within a file set for a given year. Geographic areas may not align with the same areas from another year. Some geographies are available as nation-based files while others are available only as state-based files.
262
+
263
+ Census tracts are small, relatively permanent statistical subdivisions of a county or equivalent entity, and were defined by local participants as part of the 2020 Census Participant Statistical Areas Program. The Census Bureau delineated the census tracts in situations where no local participant existed or where all the potential participants declined to participate. The primary purpose of census tracts is to provide a stable set of geographic units for the presentation of census data and comparison back to previous decennial censuses. Census tracts generally have a population size between 1,200 and 8,000 people, with an optimum size of 4,000 people. When first delineated, census tracts were designed to be homogeneous with respect to population characteristics, economic status, and living conditions. The spatial size of census tracts varies widely depending on the density of settlement. Physical changes in street patterns caused by highway construction, new development, and so forth, may require boundary revisions. In addition, census tracts occasionally are split due to population growth, or combined as a result of substantial population decline. Census tract boundaries generally follow visible and identifiable features. They may follow legal boundaries such as minor civil division (MCD) or incorporated place boundaries in some states and situations to allow for census tract-to-governmental unit relationships where the governmental boundaries tend to remain unchanged between censuses. State and county boundaries always are census tract boundaries in the standard census geographic hierarchy. In a few rare instances, a census tract may consist of noncontiguous areas. These noncontiguous areas may occur where the census tracts are coextensive with all or parts of legal entities that are themselves noncontiguous. For the 2010 Census and beyond, the census tract code range of 9400 through 9499 was enforced for census tracts that include a majority American Indian population according to Census 2000 data and/or their area was primarily covered by federally recognized American Indian reservations and/or off-reservation trust lands; the code range 9800 through 9899 was enforced for those census tracts that contained little or no population and represented a relatively large special land use area such as a National Park, military installation, or a business/industrial park; and the code range 9900 through 9998 was enforced for those census tracts that contained only water area, no land area.</gco:CharacterString>
264
+ </gmd:abstract>
265
+ <gmd:purpose>
266
+ <gco:CharacterString>These files were specifically created to support small-scale thematic mapping. To improve the appearance of shapes at small scales, areas are represented with fewer vertices than detailed TIGER/Line Shapefiles. Cartographic boundary files take up less disk space than their ungeneralized counterparts. Cartographic boundary files take less time to render on screen than TIGER/Line Shapefiles. You can join this file with table data downloaded from American FactFinder by using the AFFGEOID field in the cartographic boundary file. If detailed boundaries are required, please use the TIGER/Line Shapefiles instead of the generalized cartographic boundary files.</gco:CharacterString>
267
+ </gmd:purpose>
268
+ <gmd:status>
269
+ <gmd:MD_ProgressCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_ProgressCode"
270
+ codeListValue="completed">completed</gmd:MD_ProgressCode>
271
+ </gmd:status>
272
+ <gmd:pointOfContact>
273
+ <gmd:CI_ResponsibleParty>
274
+ <gmd:organisationName>
275
+ <gco:CharacterString>U.S. Department of Commerce, U.S. Census Bureau, Geography Division</gco:CharacterString>
276
+ </gmd:organisationName>
277
+ <gmd:contactInfo>
278
+ <gmd:CI_Contact>
279
+ <gmd:phone>
280
+ <gmd:CI_Telephone>
281
+ <gmd:voice>
282
+ <gco:CharacterString>301-763-1128</gco:CharacterString>
283
+ </gmd:voice>
284
+ <gmd:facsimile>
285
+ <gco:CharacterString>301-763-4710</gco:CharacterString>
286
+ </gmd:facsimile>
287
+ </gmd:CI_Telephone>
288
+ </gmd:phone>
289
+ <gmd:address>
290
+ <gmd:CI_Address>
291
+ <gmd:deliveryPoint>
292
+ <gco:CharacterString>4600 Silver Hill Road, Stop 7400</gco:CharacterString>
293
+ </gmd:deliveryPoint>
294
+ <gmd:city>
295
+ <gco:CharacterString>Washington</gco:CharacterString>
296
+ </gmd:city>
297
+ <gmd:administrativeArea>
298
+ <gco:CharacterString>DC</gco:CharacterString>
299
+ </gmd:administrativeArea>
300
+ <gmd:postalCode>
301
+ <gco:CharacterString>20233-7400</gco:CharacterString>
302
+ </gmd:postalCode>
303
+ <gmd:country>
304
+ <gco:CharacterString>United States</gco:CharacterString>
305
+ </gmd:country>
306
+ <gmd:electronicMailAddress>
307
+ <gco:CharacterString>geo.geography@census.gov</gco:CharacterString>
308
+ </gmd:electronicMailAddress>
309
+ </gmd:CI_Address>
310
+ </gmd:address>
311
+ </gmd:CI_Contact>
312
+ </gmd:contactInfo>
313
+ <gmd:role>
314
+ <gmd:CI_RoleCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_RoleCode"
315
+ codeListValue="pointOfContact">pointOfContact</gmd:CI_RoleCode>
316
+ </gmd:role>
317
+ </gmd:CI_ResponsibleParty>
318
+ </gmd:pointOfContact>
319
+ <gmd:resourceMaintenance>
320
+ <gmd:MD_MaintenanceInformation>
321
+ <gmd:maintenanceAndUpdateFrequency>
322
+ <gmd:MD_MaintenanceFrequencyCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_MaintenanceFrequencyCode"
323
+ codeListValue="notPlanned">notPlanned</gmd:MD_MaintenanceFrequencyCode>
324
+ </gmd:maintenanceAndUpdateFrequency>
325
+ </gmd:MD_MaintenanceInformation>
326
+ </gmd:resourceMaintenance>
327
+ <gmd:descriptiveKeywords>
328
+ <gmd:MD_Keywords>
329
+ <gmd:keyword>
330
+ <gco:CharacterString>2024</gco:CharacterString>
331
+ </gmd:keyword>
332
+ <gmd:keyword>
333
+ <gco:CharacterString>SHP</gco:CharacterString>
334
+ </gmd:keyword>
335
+ <gmd:keyword>
336
+ <gco:CharacterString>Cartographic Boundary</gco:CharacterString>
337
+ </gmd:keyword>
338
+ <gmd:keyword>
339
+ <gco:CharacterString>Census Tract</gco:CharacterString>
340
+ </gmd:keyword>
341
+ <gmd:keyword>
342
+ <gco:CharacterString>County</gco:CharacterString>
343
+ </gmd:keyword>
344
+ <gmd:keyword>
345
+ <gco:CharacterString>Generalized</gco:CharacterString>
346
+ </gmd:keyword>
347
+ <gmd:type>
348
+ <gmd:MD_KeywordTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_KeywordTypeCode"
349
+ codeListValue="theme"/>
350
+ </gmd:type>
351
+ <gmd:thesaurusName>
352
+ <gmd:CI_Citation>
353
+ <gmd:title>
354
+ <gco:CharacterString>None</gco:CharacterString>
355
+ </gmd:title>
356
+ <gmd:date gco:nilReason="unknown"/>
357
+ </gmd:CI_Citation>
358
+ </gmd:thesaurusName>
359
+ </gmd:MD_Keywords>
360
+ </gmd:descriptiveKeywords>
361
+ <gmd:descriptiveKeywords>
362
+ <gmd:MD_Keywords>
363
+ <gmd:keyword>
364
+ <gco:CharacterString>United States of America (the)</gco:CharacterString>
365
+ </gmd:keyword>
366
+ <gmd:keyword>
367
+ <gco:CharacterString>US</gco:CharacterString>
368
+ </gmd:keyword>
369
+ <gmd:type>
370
+ <gmd:MD_KeywordTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_KeywordTypeCode"
371
+ codeListValue="place"/>
372
+ </gmd:type>
373
+ <gmd:thesaurusName>
374
+ <gmd:CI_Citation>
375
+ <gmd:title>
376
+ <gco:CharacterString>ISO 3166 codes for the representation of names of countries and their subdivisions</gco:CharacterString>
377
+ </gmd:title>
378
+ <gmd:date gco:nilReason="unknown"/>
379
+ </gmd:CI_Citation>
380
+ </gmd:thesaurusName>
381
+ </gmd:MD_Keywords>
382
+ </gmd:descriptiveKeywords>
383
+ <gmd:resourceConstraints>
384
+ <gmd:MD_LegalConstraints>
385
+ <gmd:accessConstraints>
386
+ <gmd:MD_RestrictionCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_RestrictionCode"
387
+ codeListValue="otherRestrictions">otherRestrictions</gmd:MD_RestrictionCode>
388
+ </gmd:accessConstraints>
389
+ <gmd:useConstraints>
390
+ <gmd:MD_RestrictionCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_RestrictionCode"
391
+ codeListValue="otherRestrictions">otherRestrictions</gmd:MD_RestrictionCode>
392
+ </gmd:useConstraints>
393
+ <gmd:otherConstraints>
394
+ <gco:CharacterString>Access constraints: None</gco:CharacterString>
395
+ </gmd:otherConstraints>
396
+ <gmd:otherConstraints>
397
+ <gco:CharacterString>Use Constraints: The intended display scale for this file is 1:500,000. This file should not be displayed at scales larger than 1:500,000.
398
+
399
+ These products are free to use in a product or publication, however acknowledgement must be given to the U.S. Census Bureau as the source. The boundary information is for visual display at appropriate small scales only. Cartographic boundary files should not be used for geographic analysis including area or perimeter calculation. Files should not be used for geocoding addresses. Files should not be used for determining precise geographic area relationships.</gco:CharacterString>
400
+ </gmd:otherConstraints>
401
+ </gmd:MD_LegalConstraints>
402
+ </gmd:resourceConstraints>
403
+ <gmd:spatialRepresentationType>
404
+ <gmd:MD_SpatialRepresentationTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_SpatialRepresentationTypeCode"
405
+ codeListValue="vector">vector</gmd:MD_SpatialRepresentationTypeCode>
406
+ </gmd:spatialRepresentationType>
407
+ <gmd:language>
408
+ <gco:CharacterString>eng; USA</gco:CharacterString>
409
+ </gmd:language>
410
+ <gmd:characterSet>
411
+ <gmd:MD_CharacterSetCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_CharacterSetCode"
412
+ codeListValue="utf8">utf8</gmd:MD_CharacterSetCode>
413
+ </gmd:characterSet>
414
+ <gmd:topicCategory>
415
+ <gmd:MD_TopicCategoryCode>boundaries</gmd:MD_TopicCategoryCode>
416
+ </gmd:topicCategory>
417
+ <gmd:environmentDescription>
418
+ <gco:CharacterString>The cartographic boundary files contain geographic data only and do not include display mapping software or statistical data. For information on how to use cartographic boundary file data with specific software package users shall contact the company that produced the software.</gco:CharacterString>
419
+ </gmd:environmentDescription>
420
+ <gmd:extent>
421
+ <gmd:EX_Extent id="boundingExtent">
422
+ <gmd:geographicElement>
423
+ <gmd:EX_GeographicBoundingBox id="boundingGeographicBoundingBox">
424
+ <gmd:westBoundLongitude>
425
+ <gco:Decimal>-179.146711</gco:Decimal>
426
+ </gmd:westBoundLongitude>
427
+ <gmd:eastBoundLongitude>
428
+ <gco:Decimal>179.77847</gco:Decimal>
429
+ </gmd:eastBoundLongitude>
430
+ <gmd:southBoundLatitude>
431
+ <gco:Decimal>-14.548699</gco:Decimal>
432
+ </gmd:southBoundLatitude>
433
+ <gmd:northBoundLatitude>
434
+ <gco:Decimal>71.387815</gco:Decimal>
435
+ </gmd:northBoundLatitude>
436
+ </gmd:EX_GeographicBoundingBox>
437
+ </gmd:geographicElement>
438
+ <gmd:temporalElement>
439
+ <gmd:EX_TemporalExtent id="boundingTemporalExtent">
440
+ <gmd:extent>
441
+ <gml:TimeInstant gml:id="boundingTemporalExtentASingleDate">
442
+ <gml:description>publication date</gml:description>
443
+ <gml:timePosition>2025-05</gml:timePosition>
444
+ </gml:TimeInstant>
445
+ </gmd:extent>
446
+ </gmd:EX_TemporalExtent>
447
+ </gmd:temporalElement>
448
+ </gmd:EX_Extent>
449
+ </gmd:extent>
450
+ </gmd:MD_DataIdentification>
451
+ </gmd:identificationInfo>
452
+ <gmd:contentInfo>
453
+ <gmd:MD_FeatureCatalogueDescription>
454
+ <gmd:includedWithDataset>
455
+ <gco:Boolean>1</gco:Boolean>
456
+ </gmd:includedWithDataset>
457
+ <gmd:featureTypes>
458
+ <gco:LocalName codeSpace="unknown">Census Tracts</gco:LocalName>
459
+ </gmd:featureTypes>
460
+ <gmd:featureCatalogueCitation>
461
+ <gmd:CI_Citation>
462
+ <gmd:title>
463
+ <gco:CharacterString>Feature Catalog for the 2024 Census Tract for United States Cartographic Boundary File</gco:CharacterString>
464
+ </gmd:title>
465
+ <gmd:date>
466
+ <gmd:CI_Date>
467
+ <gmd:date>
468
+ <gco:Date>2024</gco:Date>
469
+ </gmd:date>
470
+ <gmd:dateType>
471
+ <gmd:CI_DateTypeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_DateTypeCode"
472
+ codeListValue="publication">publication</gmd:CI_DateTypeCode>
473
+ </gmd:dateType>
474
+ </gmd:CI_Date>
475
+ </gmd:date>
476
+ <gmd:citedResponsibleParty>
477
+ <gmd:CI_ResponsibleParty>
478
+ <gmd:organisationName>
479
+ <gco:CharacterString>U.S. Department of Commerce, U.S. Census Bureau, Geography Division</gco:CharacterString>
480
+ </gmd:organisationName>
481
+ <gmd:contactInfo>
482
+ <gmd:CI_Contact>
483
+ <gmd:phone>
484
+ <gmd:CI_Telephone>
485
+ <gmd:voice>
486
+ <gco:CharacterString>301-763-1128</gco:CharacterString>
487
+ </gmd:voice>
488
+ <gmd:facsimile>
489
+ <gco:CharacterString>301-763-4710</gco:CharacterString>
490
+ </gmd:facsimile>
491
+ </gmd:CI_Telephone>
492
+ </gmd:phone>
493
+ <gmd:address>
494
+ <gmd:CI_Address>
495
+ <gmd:deliveryPoint>
496
+ <gco:CharacterString>4600 Silver Hill Road, Stop 7400</gco:CharacterString>
497
+ </gmd:deliveryPoint>
498
+ <gmd:city>
499
+ <gco:CharacterString>Washington</gco:CharacterString>
500
+ </gmd:city>
501
+ <gmd:administrativeArea>
502
+ <gco:CharacterString>DC</gco:CharacterString>
503
+ </gmd:administrativeArea>
504
+ <gmd:postalCode>
505
+ <gco:CharacterString>20233-7400</gco:CharacterString>
506
+ </gmd:postalCode>
507
+ <gmd:country>
508
+ <gco:CharacterString>United States</gco:CharacterString>
509
+ </gmd:country>
510
+ <gmd:electronicMailAddress>
511
+ <gco:CharacterString>geo.geography@census.gov</gco:CharacterString>
512
+ </gmd:electronicMailAddress>
513
+ </gmd:CI_Address>
514
+ </gmd:address>
515
+ </gmd:CI_Contact>
516
+ </gmd:contactInfo>
517
+ <gmd:role>
518
+ <gmd:CI_RoleCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_RoleCode"
519
+ codeListValue="pointOfContact">pointOfContact</gmd:CI_RoleCode>
520
+ </gmd:role>
521
+ </gmd:CI_ResponsibleParty>
522
+ </gmd:citedResponsibleParty>
523
+ <gmd:otherCitationDetails>
524
+ <gco:CharacterString>https://meta.geo.census.gov/data/existing/decennial/GEO/CPMB/boundary/2024/19110/shp/cb_2024_us_tract_500k.shp.ea.iso.xml</gco:CharacterString>
525
+ </gmd:otherCitationDetails>
526
+ </gmd:CI_Citation>
527
+ </gmd:featureCatalogueCitation>
528
+ </gmd:MD_FeatureCatalogueDescription>
529
+ </gmd:contentInfo>
530
+ <gmd:distributionInfo>
531
+ <gmd:MD_Distribution>
532
+ <gmd:distributionFormat>
533
+ <gmd:MD_Format>
534
+ <gmd:name>
535
+ <gco:CharacterString>ZIP</gco:CharacterString>
536
+ </gmd:name>
537
+ <gmd:version gco:nilReason="unknown"/>
538
+ </gmd:MD_Format>
539
+ </gmd:distributionFormat>
540
+ <gmd:distributor>
541
+ <gmd:MD_Distributor>
542
+ <gmd:distributorContact>
543
+ <gmd:CI_ResponsibleParty>
544
+ <gmd:organisationName>
545
+ <gco:CharacterString>U.S. Department of Commerce, U.S. Census Bureau, Geography Division</gco:CharacterString>
546
+ </gmd:organisationName>
547
+ <gmd:contactInfo>
548
+ <gmd:CI_Contact>
549
+ <gmd:phone>
550
+ <gmd:CI_Telephone>
551
+ <gmd:voice>
552
+ <gco:CharacterString>301-763-1128</gco:CharacterString>
553
+ </gmd:voice>
554
+ <gmd:facsimile>
555
+ <gco:CharacterString>301-763-4710</gco:CharacterString>
556
+ </gmd:facsimile>
557
+ </gmd:CI_Telephone>
558
+ </gmd:phone>
559
+ <gmd:address>
560
+ <gmd:CI_Address>
561
+ <gmd:deliveryPoint>
562
+ <gco:CharacterString>4600 Silver Hill Road, Stop 7400</gco:CharacterString>
563
+ </gmd:deliveryPoint>
564
+ <gmd:city>
565
+ <gco:CharacterString>Washington</gco:CharacterString>
566
+ </gmd:city>
567
+ <gmd:administrativeArea>
568
+ <gco:CharacterString>DC</gco:CharacterString>
569
+ </gmd:administrativeArea>
570
+ <gmd:postalCode>
571
+ <gco:CharacterString>20233-7400</gco:CharacterString>
572
+ </gmd:postalCode>
573
+ <gmd:country>
574
+ <gco:CharacterString>United States</gco:CharacterString>
575
+ </gmd:country>
576
+ <gmd:electronicMailAddress>
577
+ <gco:CharacterString>geo.geography@census.gov</gco:CharacterString>
578
+ </gmd:electronicMailAddress>
579
+ </gmd:CI_Address>
580
+ </gmd:address>
581
+ </gmd:CI_Contact>
582
+ </gmd:contactInfo>
583
+ <gmd:role>
584
+ <gmd:CI_RoleCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_RoleCode"
585
+ codeListValue="distributor">distributor</gmd:CI_RoleCode>
586
+ </gmd:role>
587
+ </gmd:CI_ResponsibleParty>
588
+ </gmd:distributorContact>
589
+ <gmd:distributionOrderProcess>
590
+ <gmd:MD_StandardOrderProcess>
591
+ <gmd:fees>
592
+ <gco:CharacterString>The online cartographic boundary files may be downloaded without charge.</gco:CharacterString>
593
+ </gmd:fees>
594
+ </gmd:MD_StandardOrderProcess>
595
+ </gmd:distributionOrderProcess>
596
+ </gmd:MD_Distributor>
597
+ </gmd:distributor>
598
+ <gmd:transferOptions>
599
+ <gmd:MD_DigitalTransferOptions>
600
+ <gmd:onLine>
601
+ <gmd:CI_OnlineResource>
602
+ <gmd:linkage>
603
+ <gmd:URL>https://www2.census.gov/geo/tiger/GENZ2024/shp/cb_2024_us_tract_500k.zip</gmd:URL>
604
+ </gmd:linkage>
605
+ <gmd:applicationProfile>
606
+ <gco:CharacterString>Shapefile Zip File</gco:CharacterString>
607
+ </gmd:applicationProfile>
608
+ <gmd:name>
609
+ <gco:CharacterString>cb_2024_us_tract_500k.zip</gco:CharacterString>
610
+ </gmd:name>
611
+ <gmd:description>
612
+ <gco:CharacterString>The cartographic boundary files are simplified representations of selected geographic areas specifically designed for small scale thematic mapping. These files contain simplified extracts of selected geographic areas from the U.S. Census Bureau's Master Address File/Topologically Integrated Geographic Encoding and Referencing (MAF/TIGER) database.</gco:CharacterString>
613
+ </gmd:description>
614
+ <gmd:function>
615
+ <gmd:CI_OnLineFunctionCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_OnlineFunctionCode"
616
+ codeListValue="download">download</gmd:CI_OnLineFunctionCode>
617
+ </gmd:function>
618
+ </gmd:CI_OnlineResource>
619
+ </gmd:onLine>
620
+ </gmd:MD_DigitalTransferOptions>
621
+ </gmd:transferOptions>
622
+ <gmd:transferOptions>
623
+ <gmd:MD_DigitalTransferOptions>
624
+ <gmd:onLine>
625
+ <gmd:CI_OnlineResource>
626
+ <gmd:linkage>
627
+ <gmd:URL>https://meta.geo.census.gov/data/existing/decennial/GEO/CPMB/boundary/2024/19110/shp/cb_2024_us_tract_500k.shp.ea.iso.xml</gmd:URL>
628
+ </gmd:linkage>
629
+ <gmd:applicationProfile>
630
+ <gco:CharacterString>XML</gco:CharacterString>
631
+ </gmd:applicationProfile>
632
+ <gmd:name>
633
+ <gco:CharacterString>cb_2024_us_tract_500k.shp.ea.iso.xml</gco:CharacterString>
634
+ </gmd:name>
635
+ <gmd:description>
636
+ <gco:CharacterString>Entity and attribute file</gco:CharacterString>
637
+ </gmd:description>
638
+ <gmd:function>
639
+ <gmd:CI_OnLineFunctionCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_OnlineFunctionCode"
640
+ codeListValue="download">download</gmd:CI_OnLineFunctionCode>
641
+ </gmd:function>
642
+ </gmd:CI_OnlineResource>
643
+ </gmd:onLine>
644
+ </gmd:MD_DigitalTransferOptions>
645
+ </gmd:transferOptions>
646
+ <gmd:transferOptions>
647
+ <gmd:MD_DigitalTransferOptions>
648
+ <gmd:onLine>
649
+ <gmd:CI_OnlineResource>
650
+ <gmd:linkage>
651
+ <gmd:URL>https://www2.census.gov/geo/tiger/GENZ2024/2024_file_name_def.pdf</gmd:URL>
652
+ </gmd:linkage>
653
+ <gmd:applicationProfile>
654
+ <gco:CharacterString>PDF</gco:CharacterString>
655
+ </gmd:applicationProfile>
656
+ <gmd:name>
657
+ <gco:CharacterString>2024_file_name_def.pdf</gco:CharacterString>
658
+ </gmd:name>
659
+ <gmd:description>
660
+ <gco:CharacterString>The file naming convention for the cartographic boundary files.</gco:CharacterString>
661
+ </gmd:description>
662
+ <gmd:function>
663
+ <gmd:CI_OnLineFunctionCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_OnlineFunctionCode"
664
+ codeListValue="search">search</gmd:CI_OnLineFunctionCode>
665
+ </gmd:function>
666
+ </gmd:CI_OnlineResource>
667
+ </gmd:onLine>
668
+ </gmd:MD_DigitalTransferOptions>
669
+ </gmd:transferOptions>
670
+ <gmd:transferOptions>
671
+ <gmd:MD_DigitalTransferOptions>
672
+ <gmd:onLine>
673
+ <gmd:CI_OnlineResource>
674
+ <gmd:linkage>
675
+ <gmd:URL>https://www2.census.gov/geo/tiger/GENZ2024/description.pdf</gmd:URL>
676
+ </gmd:linkage>
677
+ <gmd:applicationProfile>
678
+ <gco:CharacterString>PDF</gco:CharacterString>
679
+ </gmd:applicationProfile>
680
+ <gmd:name>
681
+ <gco:CharacterString>description.pdf</gco:CharacterString>
682
+ </gmd:name>
683
+ <gmd:description>
684
+ <gco:CharacterString>General description of the cartographic boundary files and their appropriate use.</gco:CharacterString>
685
+ </gmd:description>
686
+ <gmd:function>
687
+ <gmd:CI_OnLineFunctionCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#CI_OnlineFunctionCode"
688
+ codeListValue="search">search</gmd:CI_OnLineFunctionCode>
689
+ </gmd:function>
690
+ </gmd:CI_OnlineResource>
691
+ </gmd:onLine>
692
+ </gmd:MD_DigitalTransferOptions>
693
+ </gmd:transferOptions>
694
+ </gmd:MD_Distribution>
695
+ </gmd:distributionInfo>
696
+ <gmd:dataQualityInfo>
697
+ <gmd:DQ_DataQuality>
698
+ <gmd:scope>
699
+ <gmd:DQ_Scope>
700
+ <gmd:level>
701
+ <gmd:MD_ScopeCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_ScopeCode"
702
+ codeListValue="dataset">dataset</gmd:MD_ScopeCode>
703
+ </gmd:level>
704
+ </gmd:DQ_Scope>
705
+ </gmd:scope>
706
+ <gmd:report>
707
+ <gmd:DQ_ConceptualConsistency>
708
+ <gmd:measureDescription>
709
+ <gco:CharacterString>The Census Bureau performed automated tests to ensure logical consistency and limits of shapefiles. Segments making up the outer and inner boundaries of a polygon tie end-to-end to completely enclose the area. All polygons are tested for closure.
710
+ The Census Bureau uses its internally developed geographic update system to enhance and modify spatial and attribute data in the Census MAF/TIGER database. Standard geographic codes, such as FIPS codes for states, counties, municipalities, county subdivisions, places, American Indian/Alaska Native/Native Hawaiian areas, and congressional districts are used when encoding spatial entities. The Census Bureau performed spatial data tests for logical consistency of the codes during the compilation of the original Census MAF/TIGER database files. Most of the codes for geographic entities except states, counties, urban areas, Core Based Statistical Areas (CBSAs), American Indian Areas (AIAs), and congressional districts were provided to the Census Bureau by the USGS, the agency responsible for maintaining the Geographic Names Information System (GNIS). Feature attribute information has been examined but has not been fully tested for consistency.
711
+ For the Cartographic Boundary Files, the Point and Vector Object Count for the G-polygon SDTS Point and Vector Object Type reflects the number of records in the shapefile attribute table. For multi-polygon features, only one attribute record exists for each multi-polygon rather than one attribute record per individual G-polygon component of the multi-polygon feature. Cartographic Boundary File multi-polygons are an exception to the G-polygon object type classification. Therefore, when multi-polygons exist in a shapefile, the object count will be less than the actual number of G-polygons.</gco:CharacterString>
712
+ </gmd:measureDescription>
713
+ <gmd:result gco:nilReason="unknown"/>
714
+ </gmd:DQ_ConceptualConsistency>
715
+ </gmd:report>
716
+ <gmd:report>
717
+ <gmd:DQ_CompletenessOmission>
718
+ <gmd:evaluationMethodDescription>
719
+ <gco:CharacterString>The cartographic boundary files are generalized representations of extracts taken from the MAF/TIGER database. Generalized boundary files are clipped to a simplified version of the U.S. outline. As a result, some off-shore areas may be excluded from the generalized files. Some small geographic areas, holes, or discontiguous parts of areas may not be included in generalized files if they are not visible at the target scale.</gco:CharacterString>
720
+ </gmd:evaluationMethodDescription>
721
+ <gmd:result gco:nilReason="unknown"/>
722
+ </gmd:DQ_CompletenessOmission>
723
+ </gmd:report>
724
+ <gmd:report>
725
+ <gmd:DQ_CompletenessCommission>
726
+ <gmd:evaluationMethodDescription>
727
+ <gco:CharacterString>The cartographic boundary files are generalized representations of extracts taken from the MAF/TIGER database. Generalized boundary files are clipped to a simplified version of the U.S. outline. As a result, some off-shore areas may be excluded from the generalized files. Some small geographic areas, holes, or discontiguous parts of areas may not be included in generalized files if they are not visible at the target scale.</gco:CharacterString>
728
+ </gmd:evaluationMethodDescription>
729
+ <gmd:result gco:nilReason="unknown"/>
730
+ </gmd:DQ_CompletenessCommission>
731
+ </gmd:report>
732
+ </gmd:DQ_DataQuality>
733
+ </gmd:dataQualityInfo>
734
+ <gmd:metadataMaintenance>
735
+ <gmd:MD_MaintenanceInformation>
736
+ <gmd:maintenanceAndUpdateFrequency>
737
+ <gmd:MD_MaintenanceFrequencyCode codeList="http://www.isotc211.org/2005/resources/Codelist/gmxCodelists.xml#MD_MaintenanceFrequencyCode"
738
+ codeListValue="notPlanned">notPlanned</gmd:MD_MaintenanceFrequencyCode>
739
+ </gmd:maintenanceAndUpdateFrequency>
740
+ <gmd:maintenanceNote>
741
+ <gco:CharacterString>This was transformed from the Census Bureau Geospatial Product Metadata Content Standard.</gco:CharacterString>
742
+ </gmd:maintenanceNote>
743
+ </gmd:MD_MaintenanceInformation>
744
+ </gmd:metadataMaintenance>
745
+ </gmi:MI_Metadata>
backend/app/census/cb_2024_us_tract_500k.shx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eb2c73432d63e70df1a5ad47c933337509fbcf17da2b9e09d98e04969b05a421
3
+ size 681572
backend/app/config/settings.py CHANGED
@@ -1,77 +1,56 @@
1
  from pathlib import Path
2
  from pydantic import Field
3
  from pydantic_settings import BaseSettings, SettingsConfigDict
4
- import os, tempfile
5
 
 
6
  REPO_ROOT = Path(__file__).resolve().parents[3]
7
 
8
- def _writable_dir(candidates: list[Path]) -> Path:
9
- for p in candidates:
10
- try:
11
- p.mkdir(parents=True, exist_ok=True)
12
- t = p / ".write_test"
13
- t.write_text("ok", encoding="utf-8")
14
- t.unlink(missing_ok=True)
15
- return p.resolve()
16
- except Exception:
17
- continue
18
- raise RuntimeError(f"No writable data dir from: {candidates!r}")
19
-
20
  def _default_data_dir() -> Path:
21
- candidates: list[Path] = []
22
- env = os.getenv("DATA_DIR")
23
- if env:
24
- candidates.append(Path(env))
25
- # Prefer the standard mount on Docker/HF (if writable)
26
- candidates.append(Path("/data"))
27
- # Local dev
28
- candidates.append(REPO_ROOT / "data")
29
- # Last resort
30
- candidates.append(Path(tempfile.gettempdir()) / "pulsemaps" / "data")
31
- return _writable_dir(candidates)
32
 
33
  def _default_frontend_dist() -> Path:
34
  return (REPO_ROOT / "web" / "dist").resolve()
35
 
36
  class Settings(BaseSettings):
37
- model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)
38
-
 
 
 
 
 
 
39
  OPENAI_API_KEY: str | None = None
40
  OPENAI_MODEL_AGENT: str = "gpt-4o"
41
  OPENAI_MODEL_CLASSIFIER: str = "gpt-4o-mini"
42
 
 
43
  DATA_DIR: Path = Field(default_factory=_default_data_dir)
44
- REPORTS_DB: Path | None = None
45
- SESSIONS_DB: Path | None = None
46
- UPLOADS_DIR: Path | None = None
47
  FRONTEND_DIST: Path = Field(default_factory=_default_frontend_dist)
48
 
 
49
  DEFAULT_RADIUS_KM: float = 40.0
50
  DEFAULT_LIMIT: int = 10
51
  MAX_AGE_HOURS: int = 48
52
 
 
53
  firms_map_key: str | None = None
54
  gdacs_rss_url: str | None = "https://www.gdacs.org/xml/rss.xml"
55
  nvidia_api_key: str | None = None
56
-
57
- google_maps_api_key: str | None = Field(default=None, alias="VITE_GOOGLE_MAPS_API_KEY")
58
- google_maps_map_id: str | None = Field(default=None, alias="VITE_GOOGLE_MAPS_MAP_ID")
59
-
60
 
61
  def ensure_dirs(self) -> None:
62
- if self.REPORTS_DB is None:
63
- self.REPORTS_DB = self.DATA_DIR / "pulsemaps_reports.db"
64
- if self.SESSIONS_DB is None:
65
- self.SESSIONS_DB = self.DATA_DIR / "pulsemap_sessions.db"
66
- if self.UPLOADS_DIR is None:
67
- self.UPLOADS_DIR = self.DATA_DIR / "uploads"
68
-
69
- # Make & resolve
70
  self.DATA_DIR.mkdir(parents=True, exist_ok=True)
71
  self.UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
72
- self.REPORTS_DB = self.REPORTS_DB.resolve()
73
- self.SESSIONS_DB = self.SESSIONS_DB.resolve()
74
- self.UPLOADS_DIR = self.UPLOADS_DIR.resolve()
75
 
76
  settings = Settings()
77
- settings.ensure_dirs()
 
1
  from pathlib import Path
2
  from pydantic import Field
3
  from pydantic_settings import BaseSettings, SettingsConfigDict
 
4
 
5
+ # Resolve repo root no matter where uvicorn is launched from
6
  REPO_ROOT = Path(__file__).resolve().parents[3]
7
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  def _default_data_dir() -> Path:
9
+ return (REPO_ROOT / "data").resolve()
10
+
11
+ def _default_uploads_dir() -> Path:
12
+ return (_default_data_dir() / "uploads").resolve()
 
 
 
 
 
 
 
13
 
14
  def _default_frontend_dist() -> Path:
15
  return (REPO_ROOT / "web" / "dist").resolve()
16
 
17
  class Settings(BaseSettings):
18
+ model_config = SettingsConfigDict(
19
+ env_file=".env",
20
+ extra="ignore",
21
+ case_sensitive=False,
22
+ populate_by_name=True,
23
+ )
24
+
25
+ # Models / API keys
26
  OPENAI_API_KEY: str | None = None
27
  OPENAI_MODEL_AGENT: str = "gpt-4o"
28
  OPENAI_MODEL_CLASSIFIER: str = "gpt-4o-mini"
29
 
30
+ # Paths (env may override with absolute or relative; we resolve below)
31
  DATA_DIR: Path = Field(default_factory=_default_data_dir)
32
+ REPORTS_DB: Path = Field(default_factory=lambda: _default_data_dir() / "pulsemaps_reports.db")
33
+ SESSIONS_DB: Path = Field(default_factory=lambda: _default_data_dir() / "pulsemap_sessions.db")
34
+ UPLOADS_DIR: Path = Field(default_factory=_default_uploads_dir)
35
  FRONTEND_DIST: Path = Field(default_factory=_default_frontend_dist)
36
 
37
+ # Defaults
38
  DEFAULT_RADIUS_KM: float = 40.0
39
  DEFAULT_LIMIT: int = 10
40
  MAX_AGE_HOURS: int = 48
41
 
42
+ # Optional extras you had in .env
43
  firms_map_key: str | None = None
44
  gdacs_rss_url: str | None = "https://www.gdacs.org/xml/rss.xml"
45
  nvidia_api_key: str | None = None
 
 
 
 
46
 
47
  def ensure_dirs(self) -> None:
48
+ # Resolve in case env provided relative strings
49
+ self.DATA_DIR = self.DATA_DIR.resolve()
50
+ self.UPLOADS_DIR = self.UPLOADS_DIR.resolve()
51
+ # Create everything robustly
 
 
 
 
52
  self.DATA_DIR.mkdir(parents=True, exist_ok=True)
53
  self.UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
 
 
 
54
 
55
  settings = Settings()
56
+ settings.ensure_dirs()
backend/app/data/store.py CHANGED
@@ -1,21 +1,13 @@
 
1
  from __future__ import annotations
2
  import json, sqlite3
3
  from datetime import datetime, timezone, timedelta
4
  from typing import Dict, Any, List, Optional
5
  from pathlib import Path
 
6
 
7
- from ..config.settings import settings
8
- from .geo import haversine_km
9
-
10
- DB_PATH: Path = settings.REPORTS_DB
11
- # Create parent and touch the file so we fail here if unwritable
12
- DB_PATH.parent.mkdir(parents=True, exist_ok=True)
13
- try:
14
- DB_PATH.touch(exist_ok=True)
15
- except Exception as e:
16
- raise RuntimeError(f"Cannot create DB file at {DB_PATH}: {e}")
17
-
18
- _CONN = sqlite3.connect(str(DB_PATH), check_same_thread=False)
19
  _CONN.execute("""
20
  CREATE TABLE IF NOT EXISTS reports (
21
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -38,12 +30,25 @@ def _row_to_feature(row: tuple) -> Dict[str, Any]:
38
 
39
  def add_report(lat: float, lon: float, text: str = "User report", props: dict | None = None):
40
  created_at = datetime.now(timezone.utc).isoformat()
41
- props_json = json.dumps(props or {})
42
- _CONN.execute("INSERT INTO reports (lat, lon, text, props_json, created_at) VALUES (?,?,?,?,?)",
43
- (float(lat), float(lon), text, props_json, created_at))
 
 
 
44
  _CONN.commit()
45
- return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [float(lon), float(lat)]},
46
- "properties": {"type": "user_report", "text": text, "reported_at": created_at, **(props or {})}}
 
 
 
 
 
 
 
 
 
 
47
 
48
  def get_feature_collection() -> Dict[str, Any]:
49
  cur = _CONN.execute("SELECT id, lat, lon, text, props_json, created_at FROM reports ORDER BY id DESC")
@@ -74,4 +79,22 @@ def find_reports_near(lat: float, lon: float, radius_km: float = 10.0, limit: in
74
  def clear_reports() -> dict[str, any]:
75
  _CONN.execute("DELETE FROM reports")
76
  _CONN.commit()
77
- return {"ok": True, "message": "All reports cleared."}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # same content as your current store.py, just moved here
2
  from __future__ import annotations
3
  import json, sqlite3
4
  from datetime import datetime, timezone, timedelta
5
  from typing import Dict, Any, List, Optional
6
  from pathlib import Path
7
+ from ..data.geo import haversine_km
8
 
9
+ Path("data").mkdir(exist_ok=True)
10
+ _CONN = sqlite3.connect("data/pulsemaps_reports.db", check_same_thread=False)
 
 
 
 
 
 
 
 
 
 
11
  _CONN.execute("""
12
  CREATE TABLE IF NOT EXISTS reports (
13
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
30
 
31
  def add_report(lat: float, lon: float, text: str = "User report", props: dict | None = None):
32
  created_at = datetime.now(timezone.utc).isoformat()
33
+ props = dict(props or {})
34
+ props_json = json.dumps(props)
35
+ cur = _CONN.execute(
36
+ "INSERT INTO reports (lat, lon, text, props_json, created_at) VALUES (?,?,?,?,?)",
37
+ (float(lat), float(lon), text, props_json, created_at)
38
+ )
39
  _CONN.commit()
40
+ rid = str(cur.lastrowid) # new id
41
+
42
+ # include rid in the immediate response so the UI can show counts instantly
43
+ out_props = {"type": "user_report", "text": text, "reported_at": created_at, **props}
44
+ out_props.setdefault("rid", rid)
45
+ out_props.setdefault("id", rid)
46
+
47
+ return {
48
+ "type": "Feature",
49
+ "geometry": {"type": "Point", "coordinates": [float(lon), float(lat)]},
50
+ "properties": out_props,
51
+ }
52
 
53
  def get_feature_collection() -> Dict[str, Any]:
54
  cur = _CONN.execute("SELECT id, lat, lon, text, props_json, created_at FROM reports ORDER BY id DESC")
 
79
  def clear_reports() -> dict[str, any]:
80
  _CONN.execute("DELETE FROM reports")
81
  _CONN.commit()
82
+ return {"ok": True, "message": "All reports cleared."}
83
+
84
+ def _row_to_feature(row: tuple) -> Dict[str, Any]:
85
+ _id, lat, lon, text, props_json, created_at = row
86
+ props = {"type": "user_report", "text": text, "reported_at": created_at}
87
+ if props_json:
88
+ try:
89
+ props.update(json.loads(props_json))
90
+ except Exception:
91
+ props["raw_props"] = props_json
92
+ # ✅ ensure a stable id is present on every returned feature
93
+ props.setdefault("rid", str(_id))
94
+ props.setdefault("id", str(_id)) # optional mirror
95
+ return {
96
+ "type": "Feature",
97
+ "geometry": {"type": "Point", "coordinates": [lon, lat]},
98
+ "properties": props,
99
+ }
100
+
backend/app/main.py CHANGED
@@ -9,21 +9,30 @@ app = FastAPI(title="PulseMap Agent – API", version="0.2.0")
9
 
10
  app.add_middleware(
11
  CORSMiddleware,
12
- allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
 
 
 
 
 
 
 
13
  )
14
 
15
  # Static uploads
16
  app.mount("/uploads", StaticFiles(directory=str(settings.UPLOADS_DIR)), name="uploads")
17
 
18
  # Routers
19
- from .routers import chat, reports, feeds, uploads, config # noqa
20
  from .routers.feeds import updates as updates_router
21
  app.include_router(chat.router)
22
  app.include_router(reports.router)
23
  app.include_router(feeds.router)
24
  app.include_router(updates_router)
25
  app.include_router(uploads.router)
26
- app.include_router(config.router)
 
 
27
 
28
  if settings.FRONTEND_DIST.exists():
29
  app.mount("/", StaticFiles(directory=str(settings.FRONTEND_DIST), html=True), name="spa")
 
9
 
10
  app.add_middleware(
11
  CORSMiddleware,
12
+ allow_origins=[
13
+ "http://localhost:5173",
14
+ "http://127.0.0.1:5173",
15
+ # add your deployed frontend origins too, e.g. your HF Space URL
16
+ ],
17
+ allow_credentials=True,
18
+ allow_methods=["*"],
19
+ allow_headers=["*"],
20
  )
21
 
22
  # Static uploads
23
  app.mount("/uploads", StaticFiles(directory=str(settings.UPLOADS_DIR)), name="uploads")
24
 
25
  # Routers
26
+ from .routers import chat, reports, feeds, uploads, geo, reactions, config # noqa
27
  from .routers.feeds import updates as updates_router
28
  app.include_router(chat.router)
29
  app.include_router(reports.router)
30
  app.include_router(feeds.router)
31
  app.include_router(updates_router)
32
  app.include_router(uploads.router)
33
+ app.include_router(geo.router)
34
+ app.include_router(reactions.router)
35
+ app.include_router(config.router)
36
 
37
  if settings.FRONTEND_DIST.exists():
38
  app.mount("/", StaticFiles(directory=str(settings.FRONTEND_DIST), html=True), name="spa")
backend/app/routers/config.py CHANGED
@@ -7,4 +7,4 @@ router = APIRouter(prefix="/config", tags=["config"])
7
  def runtime_config():
8
  # Only the fields you need in the client
9
  return {"VITE_GOOGLE_MAPS_API_KEY": settings.google_maps_api_key,
10
- "VITE_GOOGLE_MAPS_MAP_ID": settings.google_maps_map_id}
 
7
  def runtime_config():
8
  # Only the fields you need in the client
9
  return {"VITE_GOOGLE_MAPS_API_KEY": settings.google_maps_api_key,
10
+ "VITE_GOOGLE_MAPS_MAP_ID": settings.google_maps_map_id}
backend/app/routers/feeds.py CHANGED
@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException
2
  from typing import Any, Dict, Optional
3
  from ..services.feeds import (
4
  fetch_usgs_quakes_geojson, fetch_nws_alerts_geojson,
5
- eonet_geojson_points, firms_geojson_points, # <-- use normalized point outputs
6
  local_updates as _local_updates, global_updates as _global_updates
7
  )
8
 
 
2
  from typing import Any, Dict, Optional
3
  from ..services.feeds import (
4
  fetch_usgs_quakes_geojson, fetch_nws_alerts_geojson,
5
+ eonet_geojson_points, firms_geojson_points,
6
  local_updates as _local_updates, global_updates as _global_updates
7
  )
8
 
backend/app/routers/geo.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # apps/api/routes/geo.py
2
+ from fastapi import APIRouter, HTTPException, Query
3
+ from typing import Optional
4
+ from ..services.tracts import get_tracts_by_bbox
5
+
6
+ router = APIRouter(prefix="/geo", tags=["geo"])
7
+
8
+ @router.get("/tracts")
9
+ def tracts(bbox: str = Query(..., description="minLon,minLat,maxLon,maxLat")):
10
+ try:
11
+ minx, miny, maxx, maxy = [float(x) for x in bbox.split(",")]
12
+ except Exception:
13
+ raise HTTPException(status_code=400, detail="bbox must be minLon,minLat,maxLon,maxLat")
14
+ return get_tracts_by_bbox((minx, miny, maxx, maxy))
backend/app/routers/reactions.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # apps/api/routes/reports.py
2
+ from fastapi import APIRouter, HTTPException, Query
3
+ from pydantic import BaseModel
4
+ from typing import Literal, List
5
+ from ..services.reactions import react as svc_react, get_many as svc_get_many
6
+
7
+ router = APIRouter(prefix="/reports", tags=["reports"])
8
+
9
+ class ReactBody(BaseModel):
10
+ action: Literal["verify", "clear"]
11
+ value: bool
12
+ session_id: str
13
+
14
+ @router.post("/{rid}/react")
15
+ async def react_route(rid: str, body: ReactBody):
16
+ return await svc_react(rid, body.session_id, body.action, body.value)
17
+
18
+ @router.get("/reactions")
19
+ async def reactions(ids: str = Query(...), session_id: str = Query(...)):
20
+ id_list: List[str] = [i for i in ids.split(",") if i]
21
+ if not id_list: raise HTTPException(status_code=400, detail="ids required")
22
+ return await svc_get_many(id_list, session_id)
backend/app/services/feeds.py CHANGED
@@ -52,6 +52,7 @@ def _report_to_update(f: Dict[str, Any]) -> Dict[str, Any]:
52
  p = f.get("properties", {}) or {}
53
  lat = f["geometry"]["coordinates"][1]
54
  lon = f["geometry"]["coordinates"][0]
 
55
  return {
56
  "kind": "report",
57
  "title": p.get("title") or p.get("text") or "User report",
@@ -61,6 +62,7 @@ def _report_to_update(f: Dict[str, Any]) -> Dict[str, Any]:
61
  "severity": p.get("severity"),
62
  "sourceUrl": None,
63
  "raw": p,
 
64
  }
65
 
66
  def _quake_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None:
 
52
  p = f.get("properties", {}) or {}
53
  lat = f["geometry"]["coordinates"][1]
54
  lon = f["geometry"]["coordinates"][0]
55
+ rid = p.get("rid") or p.get("id") or p.get("_id") or p.get("uuid")
56
  return {
57
  "kind": "report",
58
  "title": p.get("title") or p.get("text") or "User report",
 
62
  "severity": p.get("severity"),
63
  "sourceUrl": None,
64
  "raw": p,
65
+ "rid": rid,
66
  }
67
 
68
  def _quake_to_update(f: Dict[str, Any]) -> Dict[str, Any] | None:
backend/app/services/reactions.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # apps/api/services/reactions.py
2
+ from __future__ import annotations
3
+ from typing import Dict, Set, List, Literal
4
+ import asyncio
5
+
6
+ Action = Literal["verify", "clear"]
7
+
8
+ # In-memory store: rid -> {"verify": set(session_id), "clear": set(session_id)}
9
+ _REACTIONS: Dict[str, Dict[str, Set[str]]] = {}
10
+ _LOCK = asyncio.Lock()
11
+
12
+ def _buckets_for(rid: str) -> Dict[str, Set[str]]:
13
+ b = _REACTIONS.get(rid)
14
+ if not b:
15
+ b = {"verify": set(), "clear": set()}
16
+ _REACTIONS[rid] = b
17
+ return b
18
+
19
+ async def react(rid: str, session_id: str, action: Action, value: bool):
20
+ async with _LOCK:
21
+ b = _buckets_for(rid)
22
+ if action == "verify":
23
+ if value:
24
+ b["verify"].add(session_id); b["clear"].discard(session_id)
25
+ else:
26
+ b["verify"].discard(session_id)
27
+ elif action == "clear":
28
+ if value:
29
+ b["clear"].add(session_id); b["verify"].discard(session_id)
30
+ else:
31
+ b["clear"].discard(session_id)
32
+ return {
33
+ "rid": rid,
34
+ "verify_count": len(b["verify"]),
35
+ "clear_count": len(b["clear"]),
36
+ "me": {
37
+ "verified": session_id in b["verify"],
38
+ "cleared": session_id in b["clear"],
39
+ },
40
+ }
41
+
42
+ async def get_many(ids: List[str], session_id: str):
43
+ out: Dict[str, dict] = {}
44
+ async with _LOCK:
45
+ for rid in ids:
46
+ b = _buckets_for(rid)
47
+ out[rid] = {
48
+ "rid": rid,
49
+ "verify_count": len(b["verify"]),
50
+ "clear_count": len(b["clear"]),
51
+ "me": {
52
+ "verified": session_id in b["verify"],
53
+ "cleared": session_id in b["clear"],
54
+ },
55
+ }
56
+ return out
backend/app/services/tracts.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # apps/api/services/tracts.py
2
+ from __future__ import annotations
3
+ from pathlib import Path
4
+ from typing import Dict, Any, Tuple, List
5
+ import geopandas as gpd
6
+ from shapely.geometry import box, mapping
7
+
8
+ BASE = Path(__file__).resolve().parent.parent
9
+ DATA_DIR = BASE / "census"
10
+ SHAPEFILE = DATA_DIR / "cb_2024_us_tract_500k.shp"
11
+
12
+ _gdf: gpd.GeoDataFrame | None = None
13
+
14
+ def _ensure_loaded() -> None:
15
+ global _gdf
16
+ if _gdf is not None:
17
+ return
18
+ if not SHAPEFILE.exists():
19
+ raise FileNotFoundError(f"Tracts shapefile not found at {SHAPEFILE}")
20
+
21
+ gdf = gpd.read_file(SHAPEFILE).to_crs(epsg=4326)
22
+ keep = [c for c in ("GEOID", "STATEFP", "NAME", "NAMELSAD") if c in gdf.columns]
23
+ gdf = gdf[keep + ["geometry"]]
24
+
25
+ # optional: simplify a bit to reduce payload size
26
+ gdf["geometry"] = gdf["geometry"].simplify(0.0005, preserve_topology=True)
27
+
28
+ # build spatial index lazily via gdf.sindex
29
+ _gdf = gdf
30
+
31
+ def get_tracts_by_bbox(bbox: Tuple[float, float, float, float]) -> Dict[str, Any]:
32
+ """
33
+ bbox = (min_lon, min_lat, max_lon, max_lat)
34
+ Returns GeoJSON FeatureCollection of tracts intersecting bbox.
35
+ """
36
+ _ensure_loaded()
37
+ assert _gdf is not None
38
+
39
+ minx, miny, maxx, maxy = bbox
40
+ qpoly = box(minx, miny, maxx, maxy)
41
+
42
+ # Use GeoPandas spatial index to get row indices (no identity headaches)
43
+ sindex = _gdf.sindex # builds on first access
44
+ idx = list(sindex.query(qpoly, predicate="intersects"))
45
+
46
+ feats: List[Dict[str, Any]] = []
47
+ for i in idx:
48
+ geom = _gdf.geometry.iat[i]
49
+ if not geom.intersects(qpoly):
50
+ continue
51
+ row = _gdf.iloc[i]
52
+ props = { "geoid": row.get("GEOID"),
53
+ "statefp": row.get("STATEFP"),
54
+ "name": row.get("NAME"),
55
+ "namelsad": row.get("NAMELSAD") }
56
+ feats.append({
57
+ "type": "Feature",
58
+ "geometry": mapping(geom), # or mapping(geom.intersection(qpoly)) to clip
59
+ "properties": props
60
+ })
61
+
62
+ return {"type": "FeatureCollection", "features": feats}
web/src/App.tsx CHANGED
@@ -9,7 +9,12 @@ import { useChat } from "./hooks/useChat";
9
  import MapCanvas from "./components/map/MapCanvas";
10
  import SelectedLocationCard from "./components/sidebar/SelectedLocationCard";
11
  import UpdatesPanel from "./components/sidebar/UpdatesPanel";
 
12
  import ChatPanel from "./components/chat/ChatPanel";
 
 
 
 
13
 
14
  export default function App() {
15
  const [selectedLL, setSelectedLL] = React.useState<[number, number] | null>(
@@ -23,8 +28,15 @@ export default function App() {
23
  type: "FeatureCollection",
24
  features: [],
25
  });
 
 
 
 
 
26
  const { nws, quakes, eonet, firms } = useFeeds();
27
 
 
 
28
  const sessionId = useSessionId();
29
  const {
30
  activeTab,
@@ -33,7 +45,7 @@ export default function App() {
33
  globalUpdates,
34
  loadingLocal,
35
  loadingGlobal,
36
- } = useUpdates(selectedLL);
37
 
38
  const {
39
  messages,
@@ -51,6 +63,25 @@ export default function App() {
51
 
52
  const fileInputRef = React.useRef<HTMLInputElement | null>(null);
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  const loadReports = React.useCallback(async () => {
55
  const fc = await fetch(REPORTS_URL)
56
  .then((r) => r.json())
@@ -58,12 +89,41 @@ export default function App() {
58
  setReports(fc);
59
  }, []);
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  React.useEffect(() => {
62
  loadReports();
63
  }, [loadReports]);
64
 
65
  const selectPoint = React.useCallback(
66
  (ll: [number, number], meta: SelectMeta) => {
 
 
 
67
  setSelectedLL(ll);
68
  setSelectedMeta(meta);
69
  },
@@ -76,6 +136,96 @@ export default function App() {
76
  if (res?.tool_used === "add_report") await loadReports();
77
  }, [send, loadReports]);
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  return (
80
  <div className="shell">
81
  <aside className="sidebar">
@@ -87,6 +237,7 @@ export default function App() {
87
  <SelectedLocationCard
88
  selectedLL={selectedLL}
89
  selectedMeta={selectedMeta}
 
90
  onClear={() => {
91
  setSelectedLL(null);
92
  setSelectedMeta(null);
@@ -100,7 +251,7 @@ export default function App() {
100
  globalUpdates={globalUpdates}
101
  loadingLocal={loadingLocal}
102
  loadingGlobal={loadingGlobal}
103
- selectedLL={selectedLL}
104
  onView={(u) =>
105
  selectPoint([u.lat, u.lon], {
106
  kind: u.kind as any,
@@ -109,10 +260,25 @@ export default function App() {
109
  severity:
110
  typeof u.severity === "undefined" ? "" : String(u.severity),
111
  sourceUrl: u.sourceUrl,
 
112
  })
113
  }
 
 
114
  />
115
  </aside>
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
  <main className="main">
118
  <section className="mapWrap" style={{ position: "relative" }}>
 
9
  import MapCanvas from "./components/map/MapCanvas";
10
  import SelectedLocationCard from "./components/sidebar/SelectedLocationCard";
11
  import UpdatesPanel from "./components/sidebar/UpdatesPanel";
12
+ import { useProximityAlerts } from "./hooks/useProximityAlerts";
13
  import ChatPanel from "./components/chat/ChatPanel";
14
+ import type { ReactionInfo, UpdateItem } from "./lib/types";
15
+ import { REACTIONS_URL, REACT_URL } from "./lib/constants";
16
+ import { useNearbyQueue } from "./hooks/useNearbyQueue";
17
+ import NearbyAlertModal from "./components/modals/NearbyAlertModal";
18
 
19
  export default function App() {
20
  const [selectedLL, setSelectedLL] = React.useState<[number, number] | null>(
 
28
  type: "FeatureCollection",
29
  features: [],
30
  });
31
+
32
+ const [reactionsById, setReactionsById] = React.useState<
33
+ Record<string, ReactionInfo>
34
+ >({});
35
+
36
  const { nws, quakes, eonet, firms } = useFeeds();
37
 
38
+ const [myLL, setMyLL] = React.useState<[number, number] | null>(null);
39
+
40
  const sessionId = useSessionId();
41
  const {
42
  activeTab,
 
45
  globalUpdates,
46
  loadingLocal,
47
  loadingGlobal,
48
+ } = useUpdates(myLL);
49
 
50
  const {
51
  messages,
 
63
 
64
  const fileInputRef = React.useRef<HTMLInputElement | null>(null);
65
 
66
+ // Try to get user location once at startup (silent fail if denied)
67
+ React.useEffect(() => {
68
+ if (!("geolocation" in navigator)) return;
69
+ navigator.geolocation.getCurrentPosition(
70
+ (pos) => setMyLL([pos.coords.latitude, pos.coords.longitude]),
71
+ () => {}, // ignore errors; panel won't show without myLL
72
+ { enableHighAccuracy: false, maximumAge: 60_000, timeout: 8_000 }
73
+ );
74
+ }, []);
75
+
76
+ // Nearby alerts (2 miles, max 5)
77
+ const {
78
+ nearby,
79
+ loading: loadingNearby,
80
+ refetch: refetchNearby,
81
+ setNearby,
82
+ } = useProximityAlerts(myLL, { radiusMiles: 2, limit: 5, maxAgeHours: 48 });
83
+ console.log("myLL:", myLL);
84
+
85
  const loadReports = React.useCallback(async () => {
86
  const fc = await fetch(REPORTS_URL)
87
  .then((r) => r.json())
 
89
  setReports(fc);
90
  }, []);
91
 
92
+ // helper to hydrate reactions for the current lists
93
+ const hydrateReactions = React.useCallback(
94
+ async (items: UpdateItem[]) => {
95
+ const ids = Array.from(
96
+ new Set(items.map((u) => u.rid).filter(Boolean))
97
+ ) as string[];
98
+ if (ids.length === 0) return;
99
+ const url = `${REACTIONS_URL}?ids=${ids.join(
100
+ ","
101
+ )}&session_id=${encodeURIComponent(sessionId)}`;
102
+ const data = await fetch(url)
103
+ .then((r) => r.json())
104
+ .catch(() => ({}));
105
+ setReactionsById((prev) => ({ ...prev, ...data }));
106
+ },
107
+ [sessionId]
108
+ );
109
+
110
+ // when updates change, hydrate reactions
111
+ React.useEffect(() => {
112
+ // hydrate both tabs so Selected card has data no matter the tab
113
+ hydrateReactions(localUpdates);
114
+ hydrateReactions(globalUpdates);
115
+ hydrateReactions(nearby);
116
+ }, [localUpdates, globalUpdates, nearby, hydrateReactions]);
117
+
118
  React.useEffect(() => {
119
  loadReports();
120
  }, [loadReports]);
121
 
122
  const selectPoint = React.useCallback(
123
  (ll: [number, number], meta: SelectMeta) => {
124
+ if (meta?.kind === "mylocation") {
125
+ setMyLL(ll); // anchor local updates to device location
126
+ }
127
  setSelectedLL(ll);
128
  setSelectedMeta(meta);
129
  },
 
136
  if (res?.tool_used === "add_report") await loadReports();
137
  }, [send, loadReports]);
138
 
139
+ // toggle handler (optimistic)
140
+ const reactOnReport = React.useCallback(
141
+ async (rid: string, action: "verify" | "clear") => {
142
+ setReactionsById((prev) => {
143
+ const cur = prev[rid] || {
144
+ verify_count: 0,
145
+ clear_count: 0,
146
+ me: { verified: false, cleared: false },
147
+ };
148
+ const want = action === "verify" ? !cur.me.verified : !cur.me.cleared;
149
+
150
+ const next: ReactionInfo = JSON.parse(JSON.stringify(cur));
151
+ if (action === "verify") {
152
+ if (want) {
153
+ next.me.verified = true;
154
+ next.verify_count += 1;
155
+ if (next.me.cleared) {
156
+ next.me.cleared = false;
157
+ next.clear_count = Math.max(0, next.clear_count - 1);
158
+ }
159
+ } else {
160
+ next.me.verified = false;
161
+ next.verify_count = Math.max(0, next.verify_count - 1);
162
+ }
163
+ } else {
164
+ if (want) {
165
+ next.me.cleared = true;
166
+ next.clear_count += 1;
167
+ if (next.me.verified) {
168
+ next.me.verified = false;
169
+ next.verify_count = Math.max(0, next.verify_count - 1);
170
+ }
171
+ } else {
172
+ next.me.cleared = false;
173
+ next.clear_count = Math.max(0, next.clear_count - 1);
174
+ }
175
+ }
176
+ return { ...prev, [rid]: next };
177
+ });
178
+
179
+ // commit to API; reconcile with truth
180
+ try {
181
+ const body = { action, value: true, session_id: sessionId };
182
+ // Ensure "value" matches our intended state (toggle)
183
+ const current = reactionsById[rid];
184
+ const want =
185
+ action === "verify" ? !current?.me.verified : !current?.me.cleared;
186
+ body.value = want;
187
+ // commit
188
+ const j = await fetch(`${REACT_URL}/${encodeURIComponent(rid)}/react`, {
189
+ method: "POST",
190
+ headers: { "Content-Type": "application/json" },
191
+ body: JSON.stringify({ action, value: want, session_id: sessionId }),
192
+ }).then((r) => r.json());
193
+ setReactionsById((prev) => ({ ...prev, [rid]: j }));
194
+ } catch {
195
+ // fallback re-hydrate
196
+ const j = await fetch(
197
+ `${REACTIONS_URL}?ids=${rid}&session_id=${encodeURIComponent(
198
+ sessionId
199
+ )}`
200
+ )
201
+ .then((r) => r.json())
202
+ .catch(() => null);
203
+ if (j && j[rid])
204
+ setReactionsById((prev) => ({ ...prev, [rid]: j[rid] }));
205
+ }
206
+ },
207
+ [sessionId, reactionsById]
208
+ );
209
+
210
+ const queue = useNearbyQueue(
211
+ nearby,
212
+ reactionsById,
213
+ sessionId,
214
+ reactOnReport,
215
+ { limit: 5 }
216
+ );
217
+ React.useEffect(() => {
218
+ if (!queue.open && queue.total > 0) queue.openQueue();
219
+ }, [queue.open, queue.total]);
220
+
221
+ const openedOnceRef = React.useRef(false);
222
+ React.useEffect(() => {
223
+ if (!openedOnceRef.current && queue.total > 0) {
224
+ queue.openQueue();
225
+ openedOnceRef.current = true;
226
+ }
227
+ }, [queue.total, queue.openQueue]);
228
+
229
  return (
230
  <div className="shell">
231
  <aside className="sidebar">
 
237
  <SelectedLocationCard
238
  selectedLL={selectedLL}
239
  selectedMeta={selectedMeta}
240
+ reactionsById={reactionsById}
241
  onClear={() => {
242
  setSelectedLL(null);
243
  setSelectedMeta(null);
 
251
  globalUpdates={globalUpdates}
252
  loadingLocal={loadingLocal}
253
  loadingGlobal={loadingGlobal}
254
+ selectedLL={myLL || selectedLL}
255
  onView={(u) =>
256
  selectPoint([u.lat, u.lon], {
257
  kind: u.kind as any,
 
260
  severity:
261
  typeof u.severity === "undefined" ? "" : String(u.severity),
262
  sourceUrl: u.sourceUrl,
263
+ rid: u.rid, // <--- include rid for Selected card
264
  })
265
  }
266
+ reactionsById={reactionsById}
267
+ onReact={reactOnReport}
268
  />
269
  </aside>
270
+ <NearbyAlertModal
271
+ open={queue.open}
272
+ leaving={queue.leaving}
273
+ current={queue.current}
274
+ index={queue.index}
275
+ total={queue.total}
276
+ myLL={myLL}
277
+ onVerify={queue.verify}
278
+ onClear={queue.clear}
279
+ onSkip={queue.skip}
280
+ onClose={queue.closeQueue}
281
+ />
282
 
283
  <main className="main">
284
  <section className="mapWrap" style={{ position: "relative" }}>
web/src/components/map/MapCanvas.tsx CHANGED
@@ -2,9 +2,9 @@ import {
2
  APIProvider,
3
  Map,
4
  AdvancedMarker,
5
- useMap,
6
  } from "@vis.gl/react-google-maps";
7
- import { useEffect, useState } from "react";
8
  import SearchControl from "./controls/SearchControl";
9
  import MyLocationControl from "./controls/MyLocationControl";
10
  import SingleSelect from "./controls/SingleSelect";
@@ -15,22 +15,19 @@ import { eonetEmoji } from "../../lib/utils";
15
  import type { FC, SelectMeta } from "../../lib/types";
16
  import { ReportIcon } from "../../components/ReportIcon";
17
  import FirmsLayer from "./overlays/FirmsLayer";
 
 
18
 
19
- function PanOnSelect({ ll }: { ll: [number, number] | null }) {
20
- const map = useMap();
21
- useEffect(() => {
22
- if (!map || !ll) return;
23
- map.panTo({ lat: ll[0], lng: ll[1] });
24
- const z = map.getZoom?.() ?? 0;
25
- if (z < 14) map.setZoom(14); // tweak 14–16 as you like
26
- }, [map, ll]);
27
- return null;
28
- }
29
-
30
- type RuntimeCfg = {
31
- VITE_GOOGLE_MAPS_API_KEY?: string;
32
- VITE_GOOGLE_MAPS_MAP_ID?: string;
33
- };
34
 
35
  export default function MapCanvas({
36
  selectedLL,
@@ -51,67 +48,20 @@ export default function MapCanvas({
51
  firms: FC | null;
52
  reports: FC;
53
  }) {
54
- const [cfg, setCfg] = useState<RuntimeCfg | null>(null);
55
- const [err, setErr] = useState<string | null>(null);
56
-
57
- useEffect(() => {
58
- fetch("/config/runtime", { cache: "no-store" })
59
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error(r.statusText))))
60
- .then((json) => {
61
- // Safe, partial logging (don’t leak the full key)
62
- const k = json?.VITE_GOOGLE_MAPS_API_KEY;
63
- const mid = json?.VITE_GOOGLE_MAPS_MAP_ID;
64
- console.log("[PulseMap] /config/runtime:", {
65
- key_present: !!k,
66
- key_len: k?.length ?? 0,
67
- key_preview: k ? `${k.slice(0, 6)}...${k.slice(-4)}` : null,
68
- map_id_present: !!mid,
69
- map_id_preview: mid ? `${mid.slice(0, 5)}...` : null,
70
- });
71
- setCfg(json);
72
- })
73
- .catch((e) => {
74
- console.error("[PulseMap] runtime config fetch failed:", e);
75
- setErr(e.message);
76
- });
77
- }, []);
78
- const effectiveKey = cfg?.VITE_GOOGLE_MAPS_API_KEY ?? GMAPS_KEY;
79
- const effectiveMapId = cfg?.VITE_GOOGLE_MAPS_MAP_ID ?? MAP_ID;
80
-
81
- useEffect(() => {
82
- console.log("[PulseMap] build-time env:", {
83
- GMAPS_KEY_present: !!GMAPS_KEY,
84
- GMAPS_KEY_len: GMAPS_KEY?.length ?? 0,
85
- GMAPS_KEY_preview: GMAPS_KEY
86
- ? `${GMAPS_KEY.slice(0, 6)}...${GMAPS_KEY.slice(-4)}`
87
- : null,
88
- MAP_ID_present: !!MAP_ID,
89
- MAP_ID_preview: MAP_ID ? `${MAP_ID.slice(0, 5)}...` : null,
90
- });
91
- }, []);
92
-
93
- if (err) return <div>Map config error: {err}</div>;
94
- if (!effectiveKey) {
95
- console.warn(
96
- "[PulseMap] No Google Maps API key found from runtime or build-time."
97
- );
98
- return <div>No Google Maps API key. Check /config/runtime and env.</div>;
99
- }
100
-
101
  return (
102
  <APIProvider
103
- apiKey={effectiveKey || GMAPS_KEY}
104
- libraries={["places", "marker"]}
105
  >
106
  <Map
107
  className="map"
108
- mapId={effectiveMapId || MAP_ID || undefined}
109
  defaultCenter={{ lat: 39, lng: -98 }}
110
  defaultZoom={4}
111
  gestureHandling="greedy"
112
  disableDefaultUI={false}
113
  >
114
- <PanOnSelect ll={selectedLL} />
115
  <SearchControl onPlace={setSelected} />
116
  <MyLocationControl onLocated={setSelected} />
117
  <SingleSelect onPick={setSelected} />
@@ -123,7 +73,14 @@ export default function MapCanvas({
123
  firms={firms}
124
  onSelect={(ll, meta) => setSelected(ll, meta)}
125
  />
126
-
 
 
 
 
 
 
 
127
  {selectedLL && (
128
  <EmojiMarker
129
  position={{ lat: selectedLL[0], lng: selectedLL[1] }}
@@ -223,7 +180,7 @@ export default function MapCanvas({
223
  }
224
  >
225
  <div className="bg-white rounded-full p-1 shadow-md border">
226
- <ReportIcon name={iconName} size={28} />
227
  </div>
228
  </AdvancedMarker>
229
  );
 
2
  APIProvider,
3
  Map,
4
  AdvancedMarker,
5
+ // useMap,
6
  } from "@vis.gl/react-google-maps";
7
+ // import { useEffect } from "react";
8
  import SearchControl from "./controls/SearchControl";
9
  import MyLocationControl from "./controls/MyLocationControl";
10
  import SingleSelect from "./controls/SingleSelect";
 
15
  import type { FC, SelectMeta } from "../../lib/types";
16
  import { ReportIcon } from "../../components/ReportIcon";
17
  import FirmsLayer from "./overlays/FirmsLayer";
18
+ import TractsLayer from "./overlays/TractsLayer";
19
+ import LegendControl from "./controls/LegendControl";
20
 
21
+ // function PanOnSelect({ ll }: { ll: [number, number] | null }) {
22
+ // const map = useMap();
23
+ // useEffect(() => {
24
+ // if (!map || !ll) return;
25
+ // map.panTo({ lat: ll[0], lng: ll[1] });
26
+ // const z = map.getZoom?.() ?? 0;
27
+ // if (z < 14) map.setZoom(14); // tweak 14–16 as you like
28
+ // }, [map, ll]);
29
+ // return null;
30
+ // }
 
 
 
 
 
31
 
32
  export default function MapCanvas({
33
  selectedLL,
 
48
  firms: FC | null;
49
  reports: FC;
50
  }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  return (
52
  <APIProvider
53
+ apiKey={GMAPS_KEY}
54
+ libraries={["places", "marker", "geometry"]}
55
  >
56
  <Map
57
  className="map"
58
+ mapId={MAP_ID}
59
  defaultCenter={{ lat: 39, lng: -98 }}
60
  defaultZoom={4}
61
  gestureHandling="greedy"
62
  disableDefaultUI={false}
63
  >
64
+ {/* <PanOnSelect ll={selectedLL} /> */}
65
  <SearchControl onPlace={setSelected} />
66
  <MyLocationControl onLocated={setSelected} />
67
  <SingleSelect onPick={setSelected} />
 
73
  firms={firms}
74
  onSelect={(ll, meta) => setSelected(ll, meta)}
75
  />
76
+ <LegendControl />
77
+ <TractsLayer
78
+ reports={reports}
79
+ eonet={eonet}
80
+ quakes={quakes}
81
+ firms={firms}
82
+ minZoom={9}
83
+ />
84
  {selectedLL && (
85
  <EmojiMarker
86
  position={{ lat: selectedLL[0], lng: selectedLL[1] }}
 
180
  }
181
  >
182
  <div className="bg-white rounded-full p-1 shadow-md border">
183
+ <ReportIcon name={iconName} size={16} />
184
  </div>
185
  </AdvancedMarker>
186
  );
web/src/components/map/controls/LegendControl.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // components/map/controls/LegendControl.tsx
2
+ import { useEffect } from "react";
3
+ import { useMap } from "@vis.gl/react-google-maps";
4
+ import { SEVERITY_COLORS } from "../../../lib/severity";
5
+
6
+ export default function LegendControl() {
7
+ const map = useMap();
8
+ useEffect(() => {
9
+ if (!map) return;
10
+ const el = document.createElement("div");
11
+ el.style.margin = "2px";
12
+ el.style.padding = "6px 8px";
13
+ el.style.borderRadius = "8px";
14
+ el.style.background = "#fff";
15
+ el.style.boxShadow = "0 1px 4px rgba(0,0,0,.3)";
16
+ el.style.font = "10px/1.2 system-ui, sans-serif";
17
+ el.innerHTML = `
18
+ <div style="font-weight:600;margin-bottom:4px">Zone Severity</div>
19
+ <div style="display:flex;gap:10px;align-items:center">
20
+ <span style="display:inline-flex;align-items:center;gap:6px">
21
+ <span style="width:10px;height:10px;background:${SEVERITY_COLORS[2]};opacity:.6;border-radius:2px;display:inline-block"></span> High
22
+ </span>
23
+ <span style="display:inline-flex;align-items:center;gap:6px">
24
+ <span style="width:10px;height:10px;background:${SEVERITY_COLORS[1]};opacity:.6;border-radius:2px;display:inline-block"></span> Med
25
+ </span>
26
+ <span style="display:inline-flex;align-items:center;gap:6px">
27
+ <span style="width:10px;height:10px;background:${SEVERITY_COLORS[0]};opacity:.6;border-radius:2px;display:inline-block"></span> Low
28
+ </span>
29
+ </div>
30
+ `;
31
+ map.controls[google.maps.ControlPosition.LEFT_BOTTOM].push(el);
32
+ return () => {
33
+ const arr = map.controls[google.maps.ControlPosition.LEFT_BOTTOM];
34
+ for (let i = 0; i < arr.getLength(); i++) {
35
+ if (arr.getAt(i) === (el as any)) {
36
+ arr.removeAt(i);
37
+ break;
38
+ }
39
+ }
40
+ };
41
+ }, [map]);
42
+ return null;
43
+ }
web/src/components/map/controls/MyLocationControl.tsx CHANGED
@@ -10,15 +10,33 @@ export default function MyLocationControl({
10
  const map = useMap();
11
  useEffect(() => {
12
  if (!map) return;
13
- const btn = document.createElement("div");
14
- btn.style.margin = "10px";
15
- btn.innerHTML = `<button aria-label="My location" style="width:40px;height:40px;border-radius:50%;background:#fff;border:0;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;font-size:18px;">📍</button>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(btn);
17
 
 
18
  const click = () => {
 
 
 
19
  if (!navigator.geolocation) return;
20
  navigator.geolocation.getCurrentPosition(
21
  (pos) => {
 
22
  const ll: [number, number] = [
23
  pos.coords.latitude,
24
  pos.coords.longitude,
@@ -27,10 +45,13 @@ export default function MyLocationControl({
27
  map.setZoom(13);
28
  onLocated(ll, { kind: "mylocation", title: "My location" });
29
  },
30
- undefined,
 
 
31
  { enableHighAccuracy: true }
32
  );
33
  };
 
34
  btn.addEventListener("click", click);
35
 
36
  return () => {
 
10
  const map = useMap();
11
  useEffect(() => {
12
  if (!map) return;
13
+ const btn = document.createElement("button");
14
+ btn.setAttribute("aria-label", "My location");
15
+ btn.style.width = "40px";
16
+ btn.style.height = "40px";
17
+ btn.style.borderRadius = "50%";
18
+ btn.style.background = "#fff";
19
+ btn.style.border = "0";
20
+ btn.style.cursor = "pointer";
21
+ btn.style.boxShadow = "0 1px 4px rgba(0,0,0,.3)";
22
+ btn.style.display = "flex";
23
+ btn.style.alignItems = "center";
24
+ btn.style.justifyContent = "center";
25
+ btn.style.fontSize = "18px";
26
+ btn.style.marginRight = "10px";
27
+ btn.textContent = "📌";
28
+
29
  map.controls[google.maps.ControlPosition.RIGHT_BOTTOM].push(btn);
30
 
31
+ let locating = false;
32
  const click = () => {
33
+ if (locating) return;
34
+ locating = true;
35
+
36
  if (!navigator.geolocation) return;
37
  navigator.geolocation.getCurrentPosition(
38
  (pos) => {
39
+ locating = false;
40
  const ll: [number, number] = [
41
  pos.coords.latitude,
42
  pos.coords.longitude,
 
45
  map.setZoom(13);
46
  onLocated(ll, { kind: "mylocation", title: "My location" });
47
  },
48
+ () => {
49
+ locating = false;
50
+ },
51
  { enableHighAccuracy: true }
52
  );
53
  };
54
+
55
  btn.addEventListener("click", click);
56
 
57
  return () => {
web/src/components/map/overlays/TractsLayer.tsx ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // components/map/overlays/TractsLayer.tsx
2
+ import * as React from "react";
3
+ import { useMap } from "@vis.gl/react-google-maps";
4
+ import type { FC } from "../../../lib/types";
5
+ import {
6
+ SEVERITY_COLORS,
7
+ toSeverityRank,
8
+ type SeverityRank,
9
+ } from "../../../lib/severity";
10
+ import { GEO_URL } from "../../../lib/constants";
11
+
12
+ type Pt = { lat: number; lon: number; rank: SeverityRank | null };
13
+
14
+ function extractReports(fc: FC): Pt[] {
15
+ const out: Pt[] = [];
16
+ (fc?.features || []).forEach((f: any) => {
17
+ if (f?.geometry?.type !== "Point") return;
18
+ const [lon, lat] = f.geometry.coordinates || [];
19
+ const p = f.properties || {};
20
+ const rank = toSeverityRank(p.severity, p.category);
21
+ out.push({ lat: Number(lat), lon: Number(lon), rank });
22
+ });
23
+ return out;
24
+ }
25
+
26
+ // natural feeds → always High rank (2)
27
+ function extractNaturalPoints(
28
+ eonet?: FC | null,
29
+ quakes?: FC | null,
30
+ firms?: FC | null
31
+ ): Pt[] {
32
+ const acc: Pt[] = [];
33
+ const pushPoints = (fc?: FC | null) => {
34
+ (fc?.features || []).forEach((f: any) => {
35
+ if (f?.geometry?.type !== "Point") return;
36
+ const [lon, lat] = f.geometry.coordinates || [];
37
+ acc.push({ lat: Number(lat), lon: Number(lon), rank: 2 });
38
+ });
39
+ };
40
+ pushPoints(eonet);
41
+ pushPoints(quakes);
42
+ pushPoints(firms);
43
+ return acc;
44
+ }
45
+
46
+ export default function TractsLayer({
47
+ reports,
48
+ eonet,
49
+ quakes,
50
+ firms,
51
+ minZoom = 11,
52
+ }: {
53
+ reports: FC;
54
+ eonet?: FC | null;
55
+ quakes?: FC | null;
56
+ firms?: FC | null;
57
+ minZoom?: number;
58
+ }) {
59
+ const map = useMap();
60
+ const [tick, setTick] = React.useState(0);
61
+
62
+ // NEW: keep live polygons + request id in refs
63
+ const polysRef = React.useRef<google.maps.Polygon[]>([]);
64
+ const reqIdRef = React.useRef(0);
65
+
66
+ const clearAll = React.useCallback(() => {
67
+ polysRef.current.forEach((p) => p.setMap(null));
68
+ polysRef.current = [];
69
+ }, []);
70
+
71
+ React.useEffect(() => {
72
+ if (!map) return;
73
+ const idle = map.addListener("idle", () => setTick((k) => k + 1));
74
+ setTick((k) => k + 1);
75
+ return () => google.maps.event.removeListener(idle);
76
+ }, [map]);
77
+
78
+ React.useEffect(() => {
79
+ if (!map) return;
80
+
81
+ // bump request id & create an abort controller for this run
82
+ const myId = ++reqIdRef.current;
83
+ const ctrl = new AbortController();
84
+
85
+ const zoom = map.getZoom?.() ?? 0;
86
+
87
+ // If zoom too small, clear immediately and bail
88
+ if (zoom < minZoom) {
89
+ clearAll();
90
+ return () => ctrl.abort();
91
+ }
92
+
93
+ const b = map.getBounds?.();
94
+ if (!b) {
95
+ clearAll();
96
+ return () => ctrl.abort();
97
+ }
98
+
99
+ const ne = b.getNorthEast(),
100
+ sw = b.getSouthWest();
101
+ const bbox = `${sw.lng()},${sw.lat()},${ne.lng()},${ne.lat()}`;
102
+
103
+ // fetch tracts for current view
104
+ fetch(`${GEO_URL}?bbox=${bbox}`, { signal: ctrl.signal })
105
+ .then((r) => r.json())
106
+ .then((fc) => {
107
+ // Ignore stale responses (if user moved/zoomed since)
108
+ if (myId !== reqIdRef.current) return;
109
+
110
+ // We're drawing fresh polygons → clear previous ones first
111
+ clearAll();
112
+
113
+ const ptsReports = extractReports(reports);
114
+ const ptsNatural = extractNaturalPoints(eonet, quakes, firms);
115
+ const pts = [...ptsReports, ...ptsNatural].filter(
116
+ (p) =>
117
+ p.lat >= sw.lat() &&
118
+ p.lat <= ne.lat() &&
119
+ p.lon >= sw.lng() &&
120
+ p.lon <= ne.lng()
121
+ );
122
+
123
+ const addPoly = (rings: google.maps.LatLngLiteral[][]) => {
124
+ const polygon = new google.maps.Polygon({
125
+ paths: rings,
126
+ strokeColor: "#000000",
127
+ strokeOpacity: 0.3,
128
+ strokeWeight: 1,
129
+ fillColor: "#9aa0a6",
130
+ fillOpacity: 0.05,
131
+ clickable: false,
132
+ zIndex: 1,
133
+ });
134
+ polygon.setMap(map);
135
+ polysRef.current.push(polygon);
136
+
137
+ // Highest severity wins
138
+ let maxRank: SeverityRank | null = null;
139
+ for (const p of pts) {
140
+ if (p.rank == null) continue;
141
+ const inside = google.maps.geometry.poly.containsLocation(
142
+ new google.maps.LatLng(p.lat, p.lon),
143
+ polygon
144
+ );
145
+ if (!inside) continue;
146
+ if (maxRank == null || p.rank > maxRank) maxRank = p.rank;
147
+ if (maxRank === 2) break;
148
+ }
149
+
150
+ if (maxRank != null) {
151
+ polygon.setOptions({
152
+ fillColor: SEVERITY_COLORS[maxRank],
153
+ fillOpacity: 0.18,
154
+ strokeOpacity: 0.12,
155
+ });
156
+ }
157
+ };
158
+
159
+ (fc.features || []).forEach((f: any) => {
160
+ const g = f.geometry || {};
161
+ const rings: google.maps.LatLngLiteral[][] = [];
162
+ if (g.type === "Polygon") {
163
+ const outer = (g.coordinates?.[0] || []).map((c: any) => ({
164
+ lat: c[1],
165
+ lng: c[0],
166
+ }));
167
+ if (outer.length) rings.push(outer);
168
+ } else if (g.type === "MultiPolygon") {
169
+ (g.coordinates || []).forEach((poly: any) => {
170
+ const outer = (poly?.[0] || []).map((c: any) => ({
171
+ lat: c[1],
172
+ lng: c[0],
173
+ }));
174
+ if (outer.length) rings.push(outer);
175
+ });
176
+ }
177
+ if (rings.length) addPoly(rings);
178
+ });
179
+ })
180
+ .catch((e) => {
181
+ if (e?.name !== "AbortError") {
182
+ // optional: console.warn("Tracts fetch failed", e);
183
+ }
184
+ });
185
+
186
+ // On effect re-run/unmount: abort in-flight request
187
+ return () => ctrl.abort();
188
+ }, [map, tick, reports, eonet, quakes, firms, minZoom, clearAll]);
189
+
190
+ return null;
191
+ }
web/src/components/modals/NearbyAlertModal.tsx ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import type { UpdateItem } from "../../lib/types";
3
+ import { haversineMiles, timeAgo } from "../../lib/geo";
4
+
5
+ export type NearbyAlertModalProps = {
6
+ open: boolean;
7
+ leaving?: boolean;
8
+ onClose: () => void;
9
+ onVerify: () => void;
10
+ onClear: () => void;
11
+ onSkip: () => void;
12
+ current: UpdateItem | null;
13
+ index: number;
14
+ total: number;
15
+ myLL: [number, number] | null;
16
+ };
17
+
18
+ export default function NearbyAlertModal(props: NearbyAlertModalProps) {
19
+ const {
20
+ open,
21
+ leaving,
22
+ onClose,
23
+ onVerify,
24
+ onClear,
25
+ onSkip,
26
+ current,
27
+ index,
28
+ total,
29
+ myLL,
30
+ } = props;
31
+ const cardRef = React.useRef<HTMLDivElement | null>(null);
32
+
33
+ React.useEffect(() => {
34
+ function onKey(e: KeyboardEvent) {
35
+ if (e.key === "Escape") onClose();
36
+ }
37
+ if (open) document.addEventListener("keydown", onKey);
38
+ return () => document.removeEventListener("keydown", onKey);
39
+ }, [open, onClose]);
40
+
41
+ React.useEffect(() => {
42
+ if (open && cardRef.current) {
43
+ // focus first actionable element (Verify) for accessibility
44
+ const btn = cardRef.current.querySelector<HTMLButtonElement>(
45
+ "button[data-primary]"
46
+ );
47
+ btn?.focus();
48
+ }
49
+ }, [open, current]);
50
+
51
+ if (!open || !current) return null;
52
+
53
+ const dist = myLL ? haversineMiles(myLL, [current.lat, current.lon]) : null;
54
+ const when = timeAgo(current.time);
55
+
56
+ const raw: any = (current as any).raw || {};
57
+ const thumb =
58
+ raw.photo_url || raw.photoUrl || raw.image_url || raw.imageUrl || null;
59
+
60
+ return (
61
+ <div
62
+ className="nearby-modal-backdrop"
63
+ style={{
64
+ position: "fixed",
65
+ inset: 0,
66
+ background: "rgba(0,0,0,0.45)",
67
+ display: "grid",
68
+ placeItems: "center",
69
+ zIndex: 50,
70
+ }}
71
+ onClick={(e) => {
72
+ if (e.target === e.currentTarget) onClose();
73
+ }}
74
+ >
75
+ <div
76
+ ref={cardRef}
77
+ role="dialog"
78
+ aria-modal="true"
79
+ className={`nearby-modal-card ${leaving ? "leaving" : ""}`}
80
+ style={{
81
+ width: "min(92vw, 520px)",
82
+ borderRadius: 16,
83
+ background: "var(--card-bg, #111)",
84
+ color: "var(--card-fg, #fff)",
85
+ boxShadow: "0 20px 60px rgba(0,0,0,0.35)",
86
+ overflow: "hidden",
87
+ transform: leaving
88
+ ? "translateY(-6px) scale(0.98)"
89
+ : "translateY(0) scale(1)",
90
+ opacity: leaving ? 0 : 1,
91
+ transition: "transform .2s ease, opacity .2s ease",
92
+ }}
93
+ >
94
+ <div
95
+ style={{
96
+ display: "flex",
97
+ alignItems: "center",
98
+ padding: "12px 14px",
99
+ gap: 8,
100
+ }}
101
+ >
102
+ <div style={{ fontSize: 20 }}>{current.emoji || "📍"}</div>
103
+ <div style={{ fontWeight: 700, fontSize: 16, flex: 1 }}>
104
+ {current.title || "Nearby alert"}
105
+ </div>
106
+ <button
107
+ aria-label="Close"
108
+ onClick={onClose}
109
+ style={{
110
+ background: "transparent",
111
+ border: 0,
112
+ color: "inherit",
113
+ fontSize: 18,
114
+ opacity: 0.8,
115
+ cursor: "pointer",
116
+ }}
117
+ >
118
+
119
+ </button>
120
+ </div>
121
+
122
+ {thumb ? (
123
+ <img
124
+ src={thumb}
125
+ alt=""
126
+ style={{ width: "100%", height: 220, objectFit: "cover" }}
127
+ onError={(e) => {
128
+ (e.target as HTMLImageElement).style.display = "none";
129
+ }}
130
+ />
131
+ ) : null}
132
+
133
+ <div style={{ padding: "12px 14px", display: "grid", gap: 8 }}>
134
+ <div style={{ opacity: 0.9, fontSize: 13 }}>
135
+ Reported within <strong>2 miles</strong>
136
+ {typeof dist === "number" ? (
137
+ <>
138
+ {" "}
139
+ • approximately <strong>{dist.toFixed(1)} mi</strong> away
140
+ </>
141
+ ) : null}{" "}
142
+ • {when}
143
+ </div>
144
+ {raw?.text ? (
145
+ <div style={{ fontSize: 14, lineHeight: 1.35, opacity: 0.95 }}>
146
+ {raw.text}
147
+ </div>
148
+ ) : null}
149
+ </div>
150
+
151
+ <div
152
+ style={{
153
+ display: "flex",
154
+ alignItems: "center",
155
+ justifyContent: "space-between",
156
+ padding: "10px 12px 14px",
157
+ gap: 8,
158
+ }}
159
+ >
160
+ <div
161
+ style={{
162
+ display: "flex",
163
+ gap: 6,
164
+ alignItems: "center",
165
+ opacity: 0.8,
166
+ fontSize: 12,
167
+ }}
168
+ >
169
+ {Array.from({ length: total }).map((_, i) => (
170
+ <span
171
+ key={i}
172
+ style={{
173
+ width: 6,
174
+ height: 6,
175
+ borderRadius: 999,
176
+ display: "inline-block",
177
+ background: i === index ? "#fff" : "rgba(255,255,255,0.35)",
178
+ }}
179
+ />
180
+ ))}
181
+ <span style={{ marginLeft: 6 }}>
182
+ {index + 1} of {total}
183
+ </span>
184
+ </div>
185
+
186
+ <div style={{ display: "flex", gap: 8 }}>
187
+ <button
188
+ onClick={onClear}
189
+ style={{
190
+ padding: "8px 12px",
191
+ borderRadius: 10,
192
+ border: "1px solid rgba(255,255,255,0.15)",
193
+ background: "transparent",
194
+ color: "inherit",
195
+ cursor: "pointer",
196
+ }}
197
+ >
198
+ Clear
199
+ </button>
200
+ <button
201
+ data-primary
202
+ onClick={onVerify}
203
+ style={{
204
+ padding: "8px 12px",
205
+ borderRadius: 10,
206
+ border: 0,
207
+ background: "#4ade80",
208
+ color: "#0b1",
209
+ fontWeight: 700,
210
+ cursor: "pointer",
211
+ }}
212
+ >
213
+ Verify
214
+ </button>
215
+ <button
216
+ onClick={onSkip}
217
+ style={{
218
+ padding: "8px 8px",
219
+ borderRadius: 8,
220
+ border: 0,
221
+ background: "transparent",
222
+ color: "inherit",
223
+ opacity: 0.8,
224
+ cursor: "pointer",
225
+ }}
226
+ title="Skip"
227
+ >
228
+ Skip
229
+ </button>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ </div>
234
+ );
235
+ }
web/src/components/sidebar/NearbyAlertsPanel.tsx ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import type { UpdateItem } from "../../lib/types";
3
+ import { haversineMiles, timeAgo } from "../../lib/geo";
4
+
5
+ export type NearbyAlertModalProps = {
6
+ open: boolean;
7
+ leaving?: boolean;
8
+ onClose: () => void;
9
+ onVerify: () => void;
10
+ onClear: () => void;
11
+ onSkip: () => void;
12
+ current: UpdateItem | null;
13
+ index: number;
14
+ total: number;
15
+ myLL: [number, number] | null;
16
+ };
17
+
18
+ export default function NearbyAlertModal(props: NearbyAlertModalProps) {
19
+ const {
20
+ open,
21
+ leaving,
22
+ onClose,
23
+ onVerify,
24
+ onClear,
25
+ onSkip,
26
+ current,
27
+ index,
28
+ total,
29
+ myLL,
30
+ } = props;
31
+ const cardRef = React.useRef<HTMLDivElement | null>(null);
32
+
33
+ React.useEffect(() => {
34
+ function onKey(e: KeyboardEvent) {
35
+ if (e.key === "Escape") onClose();
36
+ }
37
+ if (open) document.addEventListener("keydown", onKey);
38
+ return () => document.removeEventListener("keydown", onKey);
39
+ }, [open, onClose]);
40
+
41
+ React.useEffect(() => {
42
+ if (open && cardRef.current) {
43
+ // focus first actionable element (Verify) for accessibility
44
+ const btn = cardRef.current.querySelector<HTMLButtonElement>(
45
+ "button[data-primary]"
46
+ );
47
+ btn?.focus();
48
+ }
49
+ }, [open, current]);
50
+
51
+ if (!open || !current) return null;
52
+
53
+ const dist = myLL ? haversineMiles(myLL, [current.lat, current.lon]) : null;
54
+ const when = timeAgo(current.time);
55
+
56
+ const raw: any = (current as any).raw || {};
57
+ const thumb =
58
+ raw.photo_url || raw.photoUrl || raw.image_url || raw.imageUrl || null;
59
+
60
+ return (
61
+ <div
62
+ className="nearby-modal-backdrop"
63
+ style={{
64
+ position: "fixed",
65
+ inset: 0,
66
+ background: "rgba(0,0,0,0.45)",
67
+ display: "grid",
68
+ placeItems: "center",
69
+ zIndex: 50,
70
+ }}
71
+ onClick={(e) => {
72
+ if (e.target === e.currentTarget) onClose();
73
+ }}
74
+ >
75
+ <div
76
+ ref={cardRef}
77
+ role="dialog"
78
+ aria-modal="true"
79
+ className={`nearby-modal-card ${leaving ? "leaving" : ""}`}
80
+ style={{
81
+ width: "min(92vw, 520px)",
82
+ borderRadius: 16,
83
+ background: "var(--card-bg, #111)",
84
+ color: "var(--card-fg, #fff)",
85
+ boxShadow: "0 20px 60px rgba(0,0,0,0.35)",
86
+ overflow: "hidden",
87
+ transform: leaving
88
+ ? "translateY(-6px) scale(0.98)"
89
+ : "translateY(0) scale(1)",
90
+ opacity: leaving ? 0 : 1,
91
+ transition: "transform .2s ease, opacity .2s ease",
92
+ }}
93
+ >
94
+ <div
95
+ style={{
96
+ display: "flex",
97
+ alignItems: "center",
98
+ padding: "12px 14px",
99
+ gap: 8,
100
+ }}
101
+ >
102
+ <div style={{ fontWeight: 700, fontSize: 16, flex: 1 }}>
103
+ {current.title || "Nearby alert"}
104
+ </div>
105
+ <button
106
+ aria-label="Close"
107
+ onClick={onSkip}
108
+ style={{
109
+ background: "transparent",
110
+ border: 0,
111
+ color: "inherit",
112
+ fontSize: 18,
113
+ opacity: 0.8,
114
+ cursor: "pointer",
115
+ }}
116
+ >
117
+
118
+ </button>
119
+ </div>
120
+
121
+ {thumb ? (
122
+ <img
123
+ src={thumb}
124
+ alt=""
125
+ style={{ width: "100%", height: 220, objectFit: "cover" }}
126
+ onError={(e) => {
127
+ (e.target as HTMLImageElement).style.display = "none";
128
+ }}
129
+ />
130
+ ) : null}
131
+
132
+ <div style={{ padding: "12px 14px", display: "grid", gap: 8 }}>
133
+ <div style={{ opacity: 0.9, fontSize: 13 }}>
134
+ Reported within <strong>2 miles</strong>
135
+ {typeof dist === "number" ? (
136
+ <>
137
+ {" "}
138
+ • approximately <strong>{dist.toFixed(1)} mi</strong> away
139
+ </>
140
+ ) : null}{" "}
141
+ • {when}
142
+ </div>
143
+ {raw?.text ? (
144
+ <div style={{ fontSize: 14, lineHeight: 1.35, opacity: 0.95 }}>
145
+ {raw.text}
146
+ </div>
147
+ ) : null}
148
+ </div>
149
+
150
+ <div
151
+ style={{
152
+ display: "flex",
153
+ alignItems: "center",
154
+ justifyContent: "space-between",
155
+ padding: "10px 12px 14px",
156
+ gap: 8,
157
+ }}
158
+ >
159
+ <div
160
+ style={{
161
+ display: "flex",
162
+ gap: 6,
163
+ alignItems: "center",
164
+ opacity: 0.8,
165
+ fontSize: 12,
166
+ }}
167
+ >
168
+ {Array.from({ length: total }).map((_, i) => (
169
+ <span
170
+ key={i}
171
+ style={{
172
+ width: 6,
173
+ height: 6,
174
+ borderRadius: 999,
175
+ display: "inline-block",
176
+ background: i === index ? "#fff" : "rgba(255,255,255,0.35)",
177
+ }}
178
+ />
179
+ ))}
180
+ <span style={{ marginLeft: 6 }}>
181
+ {index + 1} of {total}
182
+ </span>
183
+ </div>
184
+
185
+ <div style={{ display: "flex", gap: 8 }}>
186
+ <button
187
+ onClick={onClear}
188
+ style={{
189
+ padding: "8px 12px",
190
+ borderRadius: 10,
191
+ border: "1px solid rgba(255,255,255,0.15)",
192
+ background: "transparent",
193
+ color: "inherit",
194
+ cursor: "pointer",
195
+ }}
196
+ >
197
+ Clear
198
+ </button>
199
+ <button
200
+ data-primary
201
+ onClick={onVerify}
202
+ style={{
203
+ padding: "8px 12px",
204
+ borderRadius: 10,
205
+ border: 0,
206
+ background: "#4ade80",
207
+ color: "#0b1",
208
+ fontWeight: 700,
209
+ cursor: "pointer",
210
+ }}
211
+ >
212
+ Verify
213
+ </button>
214
+ <button
215
+ onClick={onSkip}
216
+ style={{
217
+ padding: "8px 8px",
218
+ borderRadius: 8,
219
+ border: 0,
220
+ background: "transparent",
221
+ color: "inherit",
222
+ opacity: 0.8,
223
+ cursor: "pointer",
224
+ }}
225
+ title="Skip"
226
+ >
227
+ Skip
228
+ </button>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ </div>
233
+ );
234
+ }
web/src/components/sidebar/SelectedLocationCard.tsx CHANGED
@@ -12,10 +12,12 @@ export default function SelectedLocationCard({
12
  selectedLL,
13
  selectedMeta,
14
  onClear,
 
15
  }: {
16
  selectedLL: [number, number] | null;
17
  selectedMeta: SelectMeta | null;
18
  onClear: () => void;
 
19
  }) {
20
  const photoSrc =
21
  selectedMeta?.raw?.photo_url ??
@@ -26,6 +28,11 @@ export default function SelectedLocationCard({
26
  (selectedMeta as any)?.photoUrl ??
27
  null;
28
 
 
 
 
 
 
29
  const showEmoji =
30
  selectedMeta?.emoji && isEmoji(String(selectedMeta.emoji))
31
  ? String(selectedMeta.emoji)
@@ -113,6 +120,11 @@ export default function SelectedLocationCard({
113
  ) : (
114
  <div className="locDetecting">Use search, 📍, or click the map.</div>
115
  )}
 
 
 
 
 
116
 
117
  {photoSrc && (
118
  <div className="mt-2" style={{ maxHeight: 220, overflow: "auto" }}>
 
12
  selectedLL,
13
  selectedMeta,
14
  onClear,
15
+ reactionsById,
16
  }: {
17
  selectedLL: [number, number] | null;
18
  selectedMeta: SelectMeta | null;
19
  onClear: () => void;
20
+ reactionsById: Record<string, any>;
21
  }) {
22
  const photoSrc =
23
  selectedMeta?.raw?.photo_url ??
 
28
  (selectedMeta as any)?.photoUrl ??
29
  null;
30
 
31
+ const rid = selectedMeta?.rid || selectedMeta?.raw?.rid;
32
+ const rx = rid ? reactionsById[rid] : null;
33
+ const verifyCount = rx?.verify_count ?? 0;
34
+ const clearCount = rx?.clear_count ?? 0;
35
+
36
  const showEmoji =
37
  selectedMeta?.emoji && isEmoji(String(selectedMeta.emoji))
38
  ? String(selectedMeta.emoji)
 
120
  ) : (
121
  <div className="locDetecting">Use search, 📍, or click the map.</div>
122
  )}
123
+ {rid ? (
124
+ <div className="text-xs muted">
125
+ Verified: {verifyCount} · Cleared: {clearCount}
126
+ </div>
127
+ ) : null}
128
 
129
  {photoSrc && (
130
  <div className="mt-2" style={{ maxHeight: 220, overflow: "auto" }}>
web/src/components/sidebar/UpdatesPanel.tsx CHANGED
@@ -20,6 +20,8 @@ export default function UpdatesPanel({
20
  loadingGlobal,
21
  selectedLL,
22
  onView,
 
 
23
  }: {
24
  activeTab: "local" | "global";
25
  setActiveTab: (t: "local" | "global") => void;
@@ -29,6 +31,8 @@ export default function UpdatesPanel({
29
  loadingGlobal: boolean;
30
  selectedLL: [number, number] | null;
31
  onView: (u: UpdateItem) => void;
 
 
32
  }) {
33
  const renderList = (
34
  list: UpdateItem[],
@@ -40,9 +44,13 @@ export default function UpdatesPanel({
40
  {!loading && list.length === 0 && <div className="muted">{emptyMsg}</div>}
41
  {!loading &&
42
  list.map((u, i) => {
43
- // DEBUG: inspect each item
44
- // eslint-disable-next-line no-console
45
- console.debug("[UpdatesPanel] item:", u);
 
 
 
 
46
 
47
  const showEmoji =
48
  u.emoji && isEmoji(String(u.emoji)) ? u.emoji : null;
@@ -71,6 +79,28 @@ export default function UpdatesPanel({
71
  </div>
72
  )}
73
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  <button className="btn btn-ghost" onClick={() => onView(u)}>
75
  View
76
  </button>
 
20
  loadingGlobal,
21
  selectedLL,
22
  onView,
23
+ reactionsById,
24
+ onReact,
25
  }: {
26
  activeTab: "local" | "global";
27
  setActiveTab: (t: "local" | "global") => void;
 
31
  loadingGlobal: boolean;
32
  selectedLL: [number, number] | null;
33
  onView: (u: UpdateItem) => void;
34
+ reactionsById: Record<string, any>;
35
+ onReact: (rid: string, action: "verify" | "clear") => void;
36
  }) {
37
  const renderList = (
38
  list: UpdateItem[],
 
44
  {!loading && list.length === 0 && <div className="muted">{emptyMsg}</div>}
45
  {!loading &&
46
  list.map((u, i) => {
47
+ // get reaction info
48
+ const rid = u.rid;
49
+ const rx = rid ? reactionsById[rid] : null;
50
+ const meVerified = !!rx?.me?.verified;
51
+ const meCleared = !!rx?.me?.cleared;
52
+ const verifyCount = rx?.verify_count ?? 0;
53
+ const clearCount = rx?.clear_count ?? 0;
54
 
55
  const showEmoji =
56
  u.emoji && isEmoji(String(u.emoji)) ? u.emoji : null;
 
79
  </div>
80
  )}
81
  </div>
82
+ {u.kind === "report" && rid ? (
83
+ <>
84
+ <button
85
+ className={`btn btn-ghost ${
86
+ meVerified ? "btn-active" : ""
87
+ }`}
88
+ onClick={() => onReact(rid, "verify")}
89
+ title="Others also saw/heard this"
90
+ >
91
+ {meVerified ? "Verified" : "Verify"} · {verifyCount}
92
+ </button>
93
+ <button
94
+ className={`btn btn-ghost ${
95
+ meCleared ? "btn-active" : ""
96
+ }`}
97
+ onClick={() => onReact(rid, "clear")}
98
+ title="Issue is cleared/resolved"
99
+ >
100
+ {meCleared ? "Cleared" : "Clear"} · {clearCount}
101
+ </button>
102
+ </>
103
+ ) : null}
104
  <button className="btn btn-ghost" onClick={() => onView(u)}>
105
  View
106
  </button>
web/src/hooks/useFeeds.ts CHANGED
@@ -85,9 +85,6 @@ export function useFeeds() {
85
 
86
  const firmsFC = normalizeFirms(d);
87
  setFirms(firmsFC);
88
-
89
- console.log("FIRMS note:", (d && d._note) || firmsFC?._note || null);
90
- console.log("FIRMS normalized:", firmsFC?.features?.length);
91
  })();
92
 
93
  return () => {
 
85
 
86
  const firmsFC = normalizeFirms(d);
87
  setFirms(firmsFC);
 
 
 
88
  })();
89
 
90
  return () => {
web/src/hooks/useNearbyQueue.ts ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import type { UpdateItem, ReactionInfo } from "../lib/types";
3
+
4
+ export type NearbyQueueOptions = {
5
+ limit?: number; // max items to show in one run (default 5)
6
+ storageNamespace?: string; // localStorage key prefix
7
+ };
8
+
9
+ function readSeenSet(key: string): Set<string> {
10
+ try {
11
+ const raw = localStorage.getItem(key);
12
+ if (!raw) return new Set();
13
+ const arr = JSON.parse(raw);
14
+ return new Set(Array.isArray(arr) ? arr : []);
15
+ } catch {
16
+ return new Set();
17
+ }
18
+ }
19
+
20
+ function writeSeenSet(key: string, set: Set<string>) {
21
+ try {
22
+ localStorage.setItem(key, JSON.stringify(Array.from(set)));
23
+ } catch {
24
+ // ignore quota errors
25
+ }
26
+ }
27
+
28
+ export function useNearbyQueue(
29
+ nearby: UpdateItem[],
30
+ reactionsById: Record<string, ReactionInfo>,
31
+ sessionId: string,
32
+ onReact: (rid: string, action: "verify" | "clear") => void | Promise<void>,
33
+ opts: NearbyQueueOptions = {}
34
+ ) {
35
+ const { limit = 5, storageNamespace = "pm_seen_v1" } = opts;
36
+ const storageKey = React.useMemo(
37
+ () => `${storageNamespace}:${sessionId || "anon"}`,
38
+ [storageNamespace, sessionId]
39
+ );
40
+
41
+ // persistent set of rids shown to the user in this session
42
+ const seenRef = React.useRef<Set<string>>(readSeenSet(storageKey));
43
+ React.useEffect(() => {
44
+ // if sessionId changes, reload the seen set
45
+ seenRef.current = readSeenSet(storageKey);
46
+ }, [storageKey]);
47
+
48
+ const [queue, setQueue] = React.useState<UpdateItem[]>([]);
49
+ const [index, setIndex] = React.useState(0);
50
+ const [open, setOpen] = React.useState(false);
51
+ const [leaving, setLeaving] = React.useState(false);
52
+
53
+ // build a fresh queue whenever inputs change
54
+ React.useEffect(() => {
55
+ const out: UpdateItem[] = [];
56
+ for (const u of nearby) {
57
+ if (!u || u.kind !== "report" || !u.rid) continue;
58
+ const r = reactionsById[u.rid];
59
+ const already = !!(r?.me?.verified || r?.me?.cleared);
60
+ if (already) continue; // don't nag if already handled this session
61
+ if (seenRef.current.has(u.rid)) continue; // don't re-show in this session
62
+ out.push(u);
63
+ if (out.length >= limit) break;
64
+ }
65
+ setQueue(out);
66
+ setIndex(0);
67
+ setLeaving(false);
68
+ }, [nearby, reactionsById, limit]);
69
+
70
+ const current = queue[index] || null;
71
+ const total = queue.length;
72
+
73
+ const markSeen = React.useCallback(
74
+ (rid?: string | null) => {
75
+ if (!rid) return;
76
+ if (!seenRef.current.has(rid)) {
77
+ seenRef.current.add(rid);
78
+ writeSeenSet(storageKey, seenRef.current);
79
+ }
80
+ },
81
+ [storageKey]
82
+ );
83
+
84
+ const advance = React.useCallback(() => {
85
+ setIndex((i) => {
86
+ const next = i + 1;
87
+ return next < total ? next : i;
88
+ });
89
+ }, [total]);
90
+
91
+ const openQueue = React.useCallback(() => {
92
+ if (total > 0) setOpen(true);
93
+ }, [total]);
94
+
95
+ const closeQueue = React.useCallback(() => {
96
+ // fully dismiss: stop animations, clear queue, reset index
97
+ setOpen(false);
98
+ setLeaving(false);
99
+ setQueue([]);
100
+ setIndex(0);
101
+ }, []);
102
+
103
+ // unified action handler with small exit animation
104
+ async function act(action: "verify" | "clear" | "skip") {
105
+ if (!current) return;
106
+ const rid = current.rid!;
107
+ setLeaving(true);
108
+ if (action === "verify" || action === "clear") {
109
+ try {
110
+ await onReact(rid, action);
111
+ } catch {
112
+ // ignore; reconcile via hydration later
113
+ }
114
+ }
115
+ // mark as seen so we won't show it again
116
+ markSeen(rid);
117
+ // wait for the CSS transition (keep in sync with component styles)
118
+ await new Promise((r) => setTimeout(r, 220));
119
+
120
+ // move to next or close
121
+ if (index + 1 < total) {
122
+ setLeaving(false);
123
+ setIndex((i) => i + 1);
124
+ } else {
125
+ // last one: clear queue so it won't auto-reopen
126
+ setQueue([]);
127
+ setIndex(0);
128
+ closeQueue();
129
+ }
130
+ }
131
+
132
+ const verify = React.useCallback(() => act("verify"), [current, act]);
133
+ const clear = React.useCallback(() => act("clear"), [current, act]);
134
+ const skip = React.useCallback(() => act("skip"), [current, act]);
135
+
136
+ return {
137
+ // state
138
+ open,
139
+ leaving,
140
+ current,
141
+ index,
142
+ total,
143
+ queue,
144
+ // actions
145
+ openQueue,
146
+ closeQueue,
147
+ verify,
148
+ clear,
149
+ skip,
150
+ // helpers
151
+ markSeen,
152
+ } as const;
153
+ }
web/src/hooks/useProximityAlerts.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // hooks/useProximityAlerts.ts
2
+ import * as React from "react";
3
+ import type { UpdateItem } from "../lib/types";
4
+ import { UPDATES_LOCAL_URL } from "../lib/constants";
5
+
6
+ export function useProximityAlerts(
7
+ myLL: [number, number] | null,
8
+ opts: { radiusMiles?: number; limit?: number; maxAgeHours?: number } = {}
9
+ ) {
10
+ const { radiusMiles = 2, limit = 5, maxAgeHours = 48 } = opts;
11
+
12
+ const [nearby, setNearby] = React.useState<UpdateItem[]>([]);
13
+ const [loading, setLoading] = React.useState(false);
14
+ const [error, setError] = React.useState<string | null>(null);
15
+
16
+ // request id to ignore stale responses
17
+ const reqIdRef = React.useRef(0);
18
+
19
+ const refetch = React.useCallback(async () => {
20
+ if (!myLL) return;
21
+ const [lat, lon] = myLL;
22
+ setLoading(true);
23
+ setError(null);
24
+
25
+ const myId = ++reqIdRef.current;
26
+ const ctrl = new AbortController();
27
+
28
+ try {
29
+ const url = `${UPDATES_LOCAL_URL}?lat=${lat}&lon=${lon}&radius_miles=${radiusMiles}&limit=${limit}&max_age_hours=${maxAgeHours}`;
30
+ const res = await fetch(url, { signal: ctrl.signal });
31
+ const data = await res.json();
32
+
33
+ const listRaw: any[] = Array.isArray(data)
34
+ ? data
35
+ : Array.isArray(data?.items)
36
+ ? data.items
37
+ : Array.isArray(data?.updates)
38
+ ? data.updates
39
+ : Array.isArray((data as any)?.results)
40
+ ? (data as any).results
41
+ : [];
42
+
43
+ // resolve a usable rid (works even if backend forgot to surface rid at top level)
44
+ const resolveRid = (u: any) =>
45
+ u?.rid ||
46
+ u?.raw?.rid ||
47
+ u?.raw?.id ||
48
+ u?.id ||
49
+ u?._id ||
50
+ u?.raw?._id ||
51
+ u?.raw?.uuid;
52
+
53
+ if (myId !== reqIdRef.current) return; // stale
54
+ // Only user reports with an id we can react to
55
+ const normalized = listRaw
56
+ .filter((u) => u?.kind === "report")
57
+ .map((u) => {
58
+ const rid = resolveRid(u);
59
+ return rid ? { ...u, rid } : null;
60
+ })
61
+ .filter(Boolean) as UpdateItem[];
62
+ console.log("nearby fetch URL", url);
63
+ console.log("nearby raw data", data);
64
+ console.log("nearby normalized", normalized);
65
+ setNearby(normalized.slice(0, limit));
66
+ } catch (e: any) {
67
+ if (e?.name !== "AbortError")
68
+ setError(e?.message || "Failed to load nearby alerts");
69
+ } finally {
70
+ if (myId === reqIdRef.current) setLoading(false);
71
+ }
72
+
73
+ return () => ctrl.abort();
74
+ }, [myLL, radiusMiles, limit, maxAgeHours]);
75
+
76
+ React.useEffect(() => {
77
+ refetch();
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ }, [refetch, myLL?.[0], myLL?.[1]]);
80
+
81
+ return { nearby, loading, error, refetch, setNearby };
82
+ }
web/src/lib/constants.ts CHANGED
@@ -1,13 +1,7 @@
1
  export const GMAPS_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
2
  export const MAP_ID = import.meta.env.VITE_GOOGLE_MAPS_MAP_ID;
3
 
4
- const DEV = import.meta.env.DEV;
5
-
6
- // In dev: use VITE_API_BASE or localhost; in prod (HF Space): same-origin
7
- export const API_BASE = DEV
8
- ? import.meta.env.VITE_API_BASE ?? "http://localhost:8000"
9
- : "";
10
-
11
  export const REPORTS_URL = `${API_BASE}/reports`;
12
  export const CHAT_URL = `${API_BASE}/chat`;
13
  export const NWS_URL = `${API_BASE}/feeds/nws`;
@@ -15,6 +9,10 @@ export const USGS_URL = `${API_BASE}/feeds/usgs`;
15
  export const EONET_URL = `${API_BASE}/feeds/eonet`;
16
  export const FIRMS_URL = `${API_BASE}/feeds/firms`;
17
  export const UPLOAD_URL = `${API_BASE}/upload/photo`;
 
 
 
 
18
 
19
  export const UPDATES_LOCAL_URL = `${API_BASE}/updates/local`;
20
  export const UPDATES_GLOBAL_URL = `${API_BASE}/updates/global`;
 
1
  export const GMAPS_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
2
  export const MAP_ID = import.meta.env.VITE_GOOGLE_MAPS_MAP_ID;
3
 
4
+ const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000";
 
 
 
 
 
 
5
  export const REPORTS_URL = `${API_BASE}/reports`;
6
  export const CHAT_URL = `${API_BASE}/chat`;
7
  export const NWS_URL = `${API_BASE}/feeds/nws`;
 
9
  export const EONET_URL = `${API_BASE}/feeds/eonet`;
10
  export const FIRMS_URL = `${API_BASE}/feeds/firms`;
11
  export const UPLOAD_URL = `${API_BASE}/upload/photo`;
12
+ export const GEO_URL = `${API_BASE}/geo/tracts`;
13
+ // add:
14
+ export const REACTIONS_URL = `${API_BASE}/reports/reactions`;
15
+ export const REACT_URL = `${API_BASE}/reports`;
16
 
17
  export const UPDATES_LOCAL_URL = `${API_BASE}/updates/local`;
18
  export const UPDATES_GLOBAL_URL = `${API_BASE}/updates/global`;
web/src/lib/geo.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function haversineMiles(a: [number, number], b: [number, number]) {
2
+ const toRad = (x: number) => (x * Math.PI) / 180;
3
+ const R = 3958.7613; // Earth radius in miles
4
+ const dLat = toRad(b[0] - a[0]);
5
+ const dLon = toRad(b[1] - a[1]);
6
+ const lat1 = toRad(a[0]);
7
+ const lat2 = toRad(b[0]);
8
+ const sinDLat = Math.sin(dLat / 2);
9
+ const sinDLon = Math.sin(dLon / 2);
10
+ const h =
11
+ sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon;
12
+ const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
13
+ return R * c;
14
+ }
15
+
16
+ export function timeAgo(iso: string, now: Date = new Date()) {
17
+ const t = new Date(iso);
18
+ const s = Math.max(0, Math.floor((now.getTime() - t.getTime()) / 1000));
19
+ if (s < 60) return `${s}s ago`;
20
+ const m = Math.floor(s / 60);
21
+ if (m < 60) return `${m}m ago`;
22
+ const h = Math.floor(m / 60);
23
+ if (h < 24) return `${h}h ago`;
24
+ const d = Math.floor(h / 24);
25
+ return `${d}d ago`;
26
+ }
web/src/lib/severity.ts ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/severity.ts
2
+ export type SeverityRank = 0 | 1 | 2; // 0=low, 1=medium, 2=high
3
+
4
+ export const SEVERITY_COLORS: Record<SeverityRank, string> = {
5
+ 0: "#7BC96F", // low (soft green)
6
+ 1: "#F1E05A", // med (pale yellow)
7
+ 2: "#EF6A5B", // high (light red)
8
+ };
9
+
10
+ // NEW: simple natural-disaster detector (keywords are easy to tune)
11
+ const NATURAL_KEYS = [
12
+ "wildfire",
13
+ "fire",
14
+ "flood",
15
+ "hurricane",
16
+ "tornado",
17
+ "earthquake",
18
+ "quake",
19
+ "storm",
20
+ "cyclone",
21
+ "typhoon",
22
+ "tsunami",
23
+ "volcano",
24
+ "eruption",
25
+ "landslide",
26
+ "mudslide",
27
+ "avalanche",
28
+ "blizzard",
29
+ "snow",
30
+ "hail",
31
+ "heat",
32
+ "drought",
33
+ "smoke",
34
+ "dust",
35
+ "wind",
36
+ "ice",
37
+ "freezing",
38
+ "lightning",
39
+ ];
40
+ export function isNaturalCategory(category?: string | null): boolean {
41
+ const c = (category || "").toLowerCase();
42
+ return NATURAL_KEYS.some((k) => c.includes(k));
43
+ }
44
+
45
+ // Normalize any severity -> rank (with NATURAL override to High)
46
+ export function toSeverityRank(
47
+ severity: unknown,
48
+ category?: string | null
49
+ ): SeverityRank | null {
50
+ // Natural disasters => always High (red), regardless of LLM severity
51
+ if (isNaturalCategory(category)) return 2;
52
+
53
+ if (severity != null) {
54
+ const s = String(severity).toLowerCase().trim();
55
+ if (s === "high" || s === "severe" || s === "critical") return 2;
56
+ if (s === "medium" || s === "moderate") return 1;
57
+ if (s === "low" || s === "minor") return 0;
58
+ const n = Number(s);
59
+ if (Number.isFinite(n)) {
60
+ if (n >= 4) return 2;
61
+ if (n >= 2) return 1;
62
+ return 0;
63
+ }
64
+ }
65
+
66
+ // Fallback by category (non-natural)
67
+ const c = (category || "").toLowerCase();
68
+ if (
69
+ c.includes("gun") ||
70
+ c.includes("robbery") ||
71
+ c.includes("assault") ||
72
+ c.includes("shoot")
73
+ )
74
+ return 2;
75
+ if (
76
+ c.includes("accident") ||
77
+ c.includes("medical") ||
78
+ c.includes("missing") ||
79
+ c.includes("theft")
80
+ )
81
+ return 1;
82
+ if (
83
+ c.includes("road") ||
84
+ c.includes("construction") ||
85
+ c.includes("blocked") ||
86
+ c.includes("lost") ||
87
+ c.includes("bag")
88
+ )
89
+ return 0;
90
+
91
+ return null;
92
+ }
web/src/lib/types.ts CHANGED
@@ -23,6 +23,8 @@ export type SelectMeta = {
23
  emoji?: string;
24
  category?: string;
25
  raw?: any;
 
 
26
  };
27
 
28
  export type Message = {
@@ -31,6 +33,12 @@ export type Message = {
31
  image?: string;
32
  };
33
 
 
 
 
 
 
 
34
  export type UpdateItem = {
35
  kind: "report" | "quake" | "nws" | "eonet" | "fire";
36
  title: string;
@@ -40,4 +48,5 @@ export type UpdateItem = {
40
  lon: number;
41
  severity?: string | number;
42
  sourceUrl?: string;
 
43
  };
 
23
  emoji?: string;
24
  category?: string;
25
  raw?: any;
26
+ id?: string;
27
+ rid?: string;
28
  };
29
 
30
  export type Message = {
 
33
  image?: string;
34
  };
35
 
36
+ export type ReactionInfo = {
37
+ verify_count: number;
38
+ clear_count: number;
39
+ me: { verified: boolean; cleared: boolean };
40
+ };
41
+
42
  export type UpdateItem = {
43
  kind: "report" | "quake" | "nws" | "eonet" | "fire";
44
  title: string;
 
48
  lon: number;
49
  severity?: string | number;
50
  sourceUrl?: string;
51
+ rid?: string;
52
  };
web/src/style.css CHANGED
@@ -345,3 +345,102 @@ body {
345
  .msg.user {
346
  white-space: pre-wrap; /* preserves \n and wraps long lines */
347
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  .msg.user {
346
  white-space: pre-wrap; /* preserves \n and wraps long lines */
347
  }
348
+
349
+ /* Nearby panel */
350
+ .nearby-panel {
351
+ margin: 0.5rem 0 0.75rem;
352
+ }
353
+ .nearby-panel .panel-header {
354
+ display: flex;
355
+ align-items: baseline;
356
+ justify-content: space-between;
357
+ padding: 0 0.25rem 0.25rem 0.25rem;
358
+ }
359
+ .nearby-panel .panel-title {
360
+ font-weight: 600;
361
+ }
362
+ .nearby-panel .panel-subtitle {
363
+ opacity: 0.7;
364
+ font-size: 0.85rem;
365
+ }
366
+
367
+ .nearby-list {
368
+ display: flex;
369
+ flex-direction: column;
370
+ gap: 0.5rem;
371
+ }
372
+
373
+ .nearby-card {
374
+ display: grid;
375
+ grid-template-columns: 1fr auto;
376
+ gap: 0.5rem;
377
+ padding: 0.5rem;
378
+ border-radius: 0.75rem;
379
+ background: var(--card-bg, rgba(255, 255, 255, 0.04));
380
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
381
+ transition: transform 0.25s ease, opacity 0.25s ease, max-height 0.28s ease,
382
+ margin 0.28s ease, padding 0.28s ease;
383
+ max-height: 160px;
384
+ }
385
+
386
+ .nearby-card.leaving {
387
+ opacity: 0;
388
+ transform: translateY(-6px) scale(0.98);
389
+ max-height: 0;
390
+ margin: 0;
391
+ padding-top: 0;
392
+ padding-bottom: 0;
393
+ }
394
+
395
+ .nearby-main {
396
+ display: grid;
397
+ grid-template-columns: 56px 1fr;
398
+ gap: 0.5rem;
399
+ background: transparent;
400
+ border: none;
401
+ text-align: left;
402
+ cursor: pointer;
403
+ }
404
+
405
+ .nearby-thumb {
406
+ width: 56px;
407
+ height: 56px;
408
+ border-radius: 0.5rem;
409
+ object-fit: cover;
410
+ background: rgba(0, 0, 0, 0.15);
411
+ display: flex;
412
+ align-items: center;
413
+ justify-content: center;
414
+ font-size: 1.25rem;
415
+ }
416
+ .nearby-thumb.placeholder {
417
+ color: rgba(255, 255, 255, 0.8);
418
+ }
419
+
420
+ .nearby-body {
421
+ display: flex;
422
+ flex-direction: column;
423
+ gap: 0.25rem;
424
+ }
425
+ .nearby-title {
426
+ font-weight: 600;
427
+ line-height: 1.2;
428
+ }
429
+ .nearby-emoji {
430
+ margin-left: 4px;
431
+ opacity: 0.85;
432
+ }
433
+ .nearby-meta {
434
+ font-size: 0.8rem;
435
+ opacity: 0.7;
436
+ }
437
+
438
+ .nearby-actions {
439
+ display: flex;
440
+ gap: 0.25rem;
441
+ align-items: center;
442
+ justify-content: flex-end;
443
+ }
444
+ .btn.btn-ghost.btn-active {
445
+ font-weight: 600;
446
+ }