Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
254 lines (246 loc) · 20.8 KB
/
Copy pathapp.js
File metadata and controls
254 lines (246 loc) · 20.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
// Curated data is used only by the separate urgent-stop demo.
const stops=[
{id:"hess",name:"Hess Recreation Area",type:"Recreation area",place:"Danville, PA",address:"843 Meadow Ln, Danville, PA 17821",tripMinutes:145,detour:6,minutes:26,grass:5,traffic:4,restrooms:5,fenced:1,lighting:3,relief:false,tags:["🌱 115-acre recreation area","🐕 Leashed dogs permitted","🚻 Year-round restrooms","🚗 Parking"],why:"Large public recreation area with fields and trails, while avoiding a concentrated highway pet-relief area.",confidence:"High",provenance:"Official MARC park page + official park rules",source:"https://montourrec.com/hess-fields/",caveat:"Dog traffic and current grass condition are not live-verified.",reports:{grass:"Good",traffic:"Low",clean:"Good"},verified:"Prototype demo observation"},
{id:"brandy",name:"Brandy Springs Park",type:"Community park",place:"Mercer, PA",address:"197 William T Wardle Dr, Mercer, PA 16137",tripMinutes:295,detour:7,minutes:31,grass:4,traffic:2,restrooms:3,fenced:4,lighting:3,relief:true,tags:["🌱 Community park","🚗 Parking","🐕 Dog facilities on site","🧺 Recreation space"],why:"A substantial community park near the corridor. A dedicated dog area lowers the match when you prefer to avoid concentrated dog-relief spaces.",confidence:"Medium",provenance:"Official park site + current place data",source:"https://www.brandyspringspark.com/",caveat:"Field verification of pet access outside the dedicated dog park is still needed.",reports:{grass:"Good",traffic:"Medium",clean:"Unknown"},verified:"Demo data only"},
{id:"mill",name:"Mill Creek Park",type:"MetroPark",place:"Youngstown, OH",address:"1001 Canfield Rd, Youngstown, OH 44511",tripMinutes:435,detour:7,minutes:24,grass:5,traffic:3,restrooms:3,fenced:1,lighting:3,relief:false,tags:["🌳 Large park system","🐕 Leashed pets on trails + drives","🚗 Parking","🥾 Walking options"],why:"A large park system where leashed pets are explicitly permitted on trails and drives, offering more room than a highway relief area.",confidence:"High",provenance:"Official Mill Creek MetroParks rules + park information",source:"https://www.millcreekmetroparks.org/park-rules-regulations/",caveat:"Pets are not permitted in picnic, recreation, or garden areas; navigation should target a pet-permitted access point.",reports:{grass:"Good",traffic:"Medium",clean:"Good"},verified:"Demo data only"},
{id:"wildwood",name:"Wildwood Preserve Metropark",type:"Nature preserve",place:"Toledo, OH",address:"5100 W Central Ave, Toledo, OH 43615",tripMinutes:575,detour:6,minutes:29,grass:5,traffic:3,restrooms:5,fenced:1,lighting:2,relief:false,tags:["🌱 493-acre preserve","🐕 Dogs welcome on leash","🚗 Designated parking","🥾 Trails"],why:"Large preserve with extensive outdoor space. Metroparks Toledo explicitly welcomes leashed dogs in Wildwood.",confidence:"High",provenance:"Official Metroparks Toledo Wildwood page + park rules",source:"https://metroparkstoledo.com/wildwoodpreserve",caveat:"Open 7 a.m. to dark. Current dog traffic and grass condition need community verification.",reports:{grass:"Excellent",traffic:"Medium",clean:"Good"},verified:"Demo data only"},
{id:"patrick",name:"St. Patrick's County Park",type:"County park",place:"South Bend, IN",address:"50651 Laurel Rd, South Bend, IN 46637",tripMinutes:700,detour:7,minutes:33,grass:5,traffic:4,restrooms:5,fenced:1,lighting:3,relief:false,tags:["🌳 398 acres","🐕 Leashed dogs documented","🚻 Restrooms","🚗 Parking"],why:"Large county park with trails and practical traveler amenities, with leashed dogs documented in current county programming.",confidence:"High",provenance:"Official St. Joseph County park + facilities information",source:"https://www.sjcparks.org/592/St-Patricks-County-Park",caveat:"Hours and possible seasonal vehicle fees should be checked at trip time.",reports:{grass:"Good",traffic:"Low",clean:"Good"},verified:"Demo data only"},
{id:"rest",name:"I-80 Highway Rest Area",type:"Rest area",place:"Danville area, PA",address:"I-80 Rest Area, Danville, PA",tripMinutes:130,detour:0,minutes:14,grass:2,traffic:1,restrooms:5,fenced:1,lighting:5,relief:true,tags:["↪ Direct highway access","🚻 Restrooms","🐕 Pet area","💡 Lighting"],why:"The fastest option with essentially no route detour, but it conflicts with a preference to avoid concentrated pet-relief areas.",confidence:"Medium",provenance:"Public highway/rest-area information",source:"https://www.pa.gov/agencies/penndot",caveat:"Dog traffic and pet-area condition are unknown.",reports:{grass:"Limited",traffic:"High",clean:"Unknown"},verified:"Demo data only"}
];
const $=id=>document.getElementById(id);
let preferences={grass:true,traffic:true,restrooms:true,detour:true,fenced:false,lighting:false};
let routePlan=null;
let routeInputs=null;
let lifeStage="Puppy", cadenceMinutes=150;
let avoidRelief=true, maxDetour=10, dogName="Conan", current=null, urgentWindow=30;
document.querySelectorAll(".chip").forEach(b=>b.onclick=()=>{b.classList.toggle("active");preferences[b.dataset.pref]=b.classList.contains("active");b.setAttribute("aria-pressed",b.classList.contains("active"))});
document.querySelectorAll(".time-pill").forEach(b=>b.onclick=()=>{document.querySelectorAll(".time-pill").forEach(x=>x.classList.remove("active"));b.classList.add("active");urgentWindow=+b.dataset.window;renderUrgent(bestForWindow(urgentWindow));syncSelectionState()});
// Presentation state only; ranking, preferences, and navigation remain unchanged.
function syncSelectionState(alternative="best"){
document.querySelectorAll(".chip").forEach(b=>b.setAttribute("aria-pressed",b.classList.contains("active")));
document.querySelectorAll(".time-pill").forEach(b=>b.setAttribute("aria-pressed",b.classList.contains("active")));
document.querySelectorAll("[data-alt]").forEach(b=>b.setAttribute("aria-pressed",b.dataset.alt===alternative));
}
syncSelectionState();
function scoreStop(s){
let earned=0,possible=0;
const add=(enabled,value)=>{if(enabled){earned+=value;possible+=5}};
add(preferences.grass,s.grass);
add(preferences.traffic,s.traffic);
add(preferences.restrooms,s.restrooms);
add(preferences.fenced,s.fenced);
add(preferences.lighting,s.lighting);
if(preferences.detour){possible+=5;earned+=Math.max(0,5-(s.detour/Math.max(maxDetour,1))*3)}
if(avoidRelief){possible+=5;earned+=s.relief?0:5}
const raw=possible?earned/possible:0.7;
// Traffic scores run from 1 (high traffic) to 5 (low traffic).
const lifeBonus=lifeStage==="Puppy"?(s.traffic-3)*1.5:lifeStage==="Senior"?Math.max(-3,3-s.detour/2):0;
const excessPenalty=s.detour>maxDetour?10+(s.detour-maxDetour)*6:0;
return Math.max(0,Math.min(98,Math.round(raw*100+lifeBonus-excessPenalty)));
}
function rankedStops(){return stops.map(s=>({...s,match:scoreStop(s)})).sort((a,b)=>b.match-a.match||a.detour-b.detour)}
function navUrl(s){return "https://www.google.com/maps/dir/?api=1&destination="+encodeURIComponent(s.address)}
function prov(s){return `<strong>${s.confidence} confidence</strong><br>${s.provenance}<br><a class="source-link" href="${s.source}" target="_blank" rel="noopener">View source ↗</a>`}
function reason(s){
const reasons=[];
if(preferences.grass&&s.grass>=4) reasons.push("large green space");
if(preferences.traffic&&s.traffic>=4) reasons.push("lower expected dog traffic");
if(preferences.restrooms&&s.restrooms>=4) reasons.push("restrooms");
if(preferences.detour&&s.detour<=7) reasons.push("small route detour");
if(avoidRelief&&!s.relief) reasons.push("not a dedicated highway dog-relief area");
return reasons.length?`It matches your priorities for ${reasons.slice(0,-1).join(", ")}${reasons.length>1?" and ":""}${reasons.slice(-1)}.`:s.why;
}
function formatMinutes(minutes){const rounded=Math.round(minutes);return `${Math.floor(rounded/60)}h ${rounded%60}m`}
function plannedTargets(){return routePlan.breakTargetsMinutes}
function plannedTimingPenalty(actualMinutes,targetMinutes){
const delta=actualMinutes-targetMinutes;
return Math.max(0,Math.abs(delta)-15)*(delta<0?0.20:0.75);
}
function timingLabel(actual,target){
const delta=actual-target;
if(Math.abs(delta)<=15) return "Close to planned break";
return delta<0?`${-delta} min early`:`${delta} min after planned break`;
}
// Escape every public string before inserting recommendation markup.
function escapeHtml(value){return String(value).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
function readableType(type){
const normalized=type.replace(/[_-]/g," ").trim();
if(normalized==="dedicated dog area") return "Dog area";
return normalized.replace(/^./,c=>c.toUpperCase());
}
function supported(attribute){return attribute?.confidence==="confirmed"||attribute?.confidence==="inferred"}
function safePublicUrl(value,googleOnly=false){
if(typeof value!=="string"||/[\s\\]/.test(value)) return null;
try{
const url=new URL(value);
if(url.protocol!=="https:"||url.username||url.password) return null;
if(googleOnly&&!['www.google.com','maps.google.com','maps.app.goo.gl','goo.gl'].includes(url.hostname)) return null;
return url.href;
}catch{return null}
}
function recommendationNavUrl(stop){
if(Number.isFinite(stop.lat)&&Math.abs(stop.lat)<=90&&Number.isFinite(stop.lng)&&Math.abs(stop.lng)<=180&&routeInputs){
return "https://www.google.com/maps/dir/?api=1&origin="+encodeURIComponent(routeInputs.origin)
+"&destination="+encodeURIComponent(routeInputs.destination)+"&waypoints="+encodeURIComponent(`${stop.lat},${stop.lng}`)+"&travelmode=driving";
}
return safePublicUrl(stop.navigation?.googleMapsUrl,true);
}
function renderRecommendation({targetMinutes,stop},index){
const label=`<div class="rec-label">PLANNED BREAK ${index+1} · ${formatMinutes(targetMinutes)}</div>`;
if(!validPublicStop(stop)) return `<article class="stop coverage-gap">${label}
<h3>No strong Pawstop match for this break</h3>
<p class="meta">No recommendation met the current timing and fit requirements for this break. Consider stopping sooner if your dog needs a break.</p>
</article>`;
const tags=[];
for(const [key,title] of Object.entries({largeGrass:"Large grass",restrooms:"Restrooms",dogsAllowed:"Dogs allowed",fenced:"Fenced",lighting:"Good lighting",parking:"Parking",dedicatedDogArea:"Dog area"})){
const attribute=stop.attributes?.[key];
if(attribute?.value===true&&supported(attribute)) tags.push(`${title}${attribute.confidence==="inferred"?" · inferred":""}`);
}
const traffic=stop.attributes?.dogTraffic;
if(supported(traffic)&&["low","medium","high"].includes(traffic.value)) tags.push(`${readableType(traffic.value)} dog traffic${traffic.confidence==="inferred"?" · inferred":""}`);
const dogs=stop.attributes?.dogsAllowed;
const dogAccess=dogs?.value===true&&supported(dogs)?null:"Dog access not confirmed";
const evidenceLabel={strong:"STRONG EVIDENCE",moderate:"MODERATE EVIDENCE",limited:"LIMITED EVIDENCE",very_limited:"VERY LIMITED EVIDENCE"}[stop.pawstop.evidenceStrength];
const detour=Math.round(stop.route.detourMinutes);
const navigation=recommendationNavUrl(stop);
const website=safePublicUrl(stop.verification?.placeWebsiteUrl);
// The dedicated caveat already covers this fact; keep distinct evidence reasons.
const why=stop.pawstop.why.filter(reason=>dogs?.confidence!=="unknown"
||!/^dog(?:s)? (?:access|permission|allowance|allowed)(?: is| are|:)? (?:not (?:yet )?confirmed|unconfirmed|unknown|uncertain|not (?:yet )?verified|not known)[.!]?$/i.test(reason.trim())).slice(0,4);
return `<article class="stop recommended real-stop">${label}
<div class="stop-head"><h3>${escapeHtml(stop.name)}</h3><span class="match">${Math.round(stop.pawstop.matchScore)} FIT</span></div>
${typeof stop.address==="string"&&stop.address.trim()?`<p class="place">${escapeHtml(stop.address)}</p>`:""}
<div class="recommendation-summary">${typeof stop.primaryType==="string"&&stop.primaryType.trim()?`<span class="type-badge">${escapeHtml(readableType(stop.primaryType))}</span>`:""}${evidenceLabel?`<span class="evidence-badge">${evidenceLabel}</span>`:""}</div>
<div class="recommendation-timing"><span>${formatMinutes(stop.route.tripMinutes)} into trip · ${detour?`+${detour} min detour`:"No meaningful route detour"}</span><span>${escapeHtml(stop.route.timingLabel)}</span></div>
${tags.length?`<div class="tags">${tags.map(tag=>`<span>${escapeHtml(tag)}</span>`).join("")}</div>`:""}
${why.length?`<div class="why"><strong>Why Pawstop picked this</strong><ul>${why.map(reason=>`<li>${escapeHtml(reason)}</li>`).join("")}</ul></div>`:""}
${dogAccess?`<p class="dog-access">${dogAccess}</p>`:""}
${website?`<a class="source-link place-website" href="${escapeHtml(website)}" target="_blank" rel="noopener">Place website ↗</a>`:""}
${navigation?`<div class="stop-actions"><a class="recommendation-nav" href="${escapeHtml(navigation)}" target="_blank" rel="noopener">Open in Google Maps</a></div>`:""}
</article>`;
}
function renderStops(){
const route=routePlan.route;
$("routeMetrics").textContent=`${formatMinutes(route.durationMinutes)} driving · ${(route.distanceMeters/1609.344).toLocaleString(undefined,{maximumFractionDigits:0})} miles · Traffic not included`;
$("routeLogic").textContent="Pawstop ranks real stops around each planned break using route timing, detour, and the preferences with available evidence. Gaps remain explicit where no strong match was found.";
$("stops").innerHTML=routePlan.recommendations.map(renderRecommendation).join("")||'<article class="stop"><h3>No planned breaks before arrival</h3><p class="meta">This route is within your selected break cadence. Stop whenever your dog needs a break.</p></article>';
}
function validPublicStop(stop){
return stop&&typeof stop.name==="string"&&stop.name.trim()&&stop.route&&stop.pawstop
&&Number.isFinite(stop.route.tripMinutes)&&Number.isFinite(stop.route.detourMinutes)
&&typeof stop.route.timingLabel==="string"&&Number.isFinite(stop.pawstop.matchScore)
&&Array.isArray(stop.pawstop.why)&&stop.pawstop.why.every(why=>typeof why==="string");
}
const routeErrors={
INVALID_INPUT:"Please check your origin, destination, and dog profile, then try again.",
NO_STOP_CANDIDATES:"We couldn't find a suitable Pawstop along this route.",
ROUTE_NOT_FOUND:"We couldn't find a drivable route between those locations. Try more specific addresses.",
PROVIDER_ERROR:"We couldn't load your route right now. Please try again shortly.",
RATE_LIMITED:"Route planning is busy right now. Please try again shortly."
};
$("planBtn").onclick=async()=>{
if($("planBtn").disabled) return;
const request={
origin:$("from").value.trim(),destination:$("to").value.trim(),
dogName:$("dog").value.trim()||"Your dog",lifeStage:$("age").value.toLowerCase(),
breakCadenceMinutes:Number($("break").value)*60,maxDetourMinutes:Number($("detour").value),
preferences:{largeGrass:preferences.grass,lowDogTraffic:preferences.traffic,restrooms:preferences.restrooms,
minimalDetours:preferences.detour,fencedSpace:preferences.fenced,goodLighting:preferences.lighting,
avoidDedicatedReliefAreas:$("avoidRelief").checked}
};
$("planBtn").disabled=true;
$("planBtn").textContent="Building your route…";
$("planner").setAttribute("aria-busy","true");
$("planError").textContent="";
$("planError").classList.add("hidden");
$("planStatus").textContent="Finding your driving route…";
try{
const response=await fetch("/api/plan-route",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(request),signal:AbortSignal.timeout(20000)});
const data=await response.json();
if(!response.ok) throw new Error(Object.hasOwn(routeErrors,data?.error?.code)?data.error.code:"PROVIDER_ERROR");
if(!data?.route||!Number.isFinite(data.route.durationMinutes)||data.route.durationMinutes<=0
||!Number.isFinite(data.route.distanceMeters)||data.route.distanceMeters<=0
||!Array.isArray(data.breakTargetsMinutes)||!data.breakTargetsMinutes.every((t,i,all)=>Number.isFinite(t)&&t>0&&t<data.route.durationMinutes&&(i===0||t>all[i-1]))
||!Array.isArray(data.recommendations)||data.recommendations.length!==data.breakTargetsMinutes.length
||!data.recommendations.every((r,i)=>r&&r.targetMinutes===data.breakTargetsMinutes[i]&&Object.hasOwn(r,"stop"))) throw new Error("PROVIDER_ERROR");
routePlan=data;
routeInputs={origin:request.origin,destination:request.destination};
dogName=request.dogName;avoidRelief=request.preferences.avoidDedicatedReliefAreas;
maxDetour=request.maxDetourMinutes;lifeStage=request.lifeStage[0].toUpperCase()+request.lifeStage.slice(1);
cadenceMinutes=request.breakCadenceMinutes;
$("routeFrom").textContent=request.origin;$("routeTo").textContent=request.destination;
$("dogName").textContent=dogName;$("profile").textContent=lifeStage;
$("breakPlan").textContent="Every "+formatMinutes(cadenceMinutes);
$("maxDetour").textContent=maxDetour+" min";
renderStops();
$("planner").classList.add("hidden");$("route").classList.remove("hidden");scrollTo(0,0);
}catch(error){
$("planError").textContent=Object.hasOwn(routeErrors,error.message)?routeErrors[error.message]:routeErrors.PROVIDER_ERROR;
$("planError").classList.remove("hidden");
}finally{
$("planBtn").disabled=false;$("planBtn").textContent="Build my Pawstop route →";
$("planner").setAttribute("aria-busy","false");$("planStatus").textContent="";
}
};
$("backBtn").onclick=()=>{$("route").classList.add("hidden");$("planner").classList.remove("hidden");scrollTo(0,0)};
function urgentEta(s){return s.minutes+s.detour}
function urgentRank(windowMins){
const ranked=rankedStops();
const within=ranked.filter(s=>urgentEta(s)<=windowMins);
const candidates=within.length?within:ranked;
const weights=windowMins<=15?{quality:0.2,time:3,detour:3}:windowMins<=30?{quality:0.65,time:1,detour:1.5}:{quality:1,time:0.2,detour:0.5};
const fit=s=>weights.quality*s.match-weights.time*urgentEta(s)-weights.detour*s.detour;
// If nothing is within the window, show the soonest option and label it honestly.
return candidates.sort((a,b)=>(within.length?0:urgentEta(a)-urgentEta(b))||fit(b)-fit(a)||a.detour-b.detour);
}
function bestForWindow(windowMins){return urgentRank(windowMins)[0]}
function renderUrgent(s){
current=s;
$("urgentTitle").textContent=`Demo stop for ${dogName}`;
$("urgentMatch").textContent=s.match+"% MATCH";
$("urgentType").textContent=s.type.toUpperCase();
$("urgentName").textContent=s.name;
$("urgentPlace").textContent=s.place;
$("urgentMinutes").textContent=urgentEta(s);
$("urgentDetour").textContent=(s.detour?`+${s.detour} min`:"No")+" estimated route detour";
const eta=urgentEta(s);
const within=eta<=urgentWindow;
$("urgentStatus").textContent=`${eta} min to stop including detour · ${within?`within your ${urgentWindow}-minute window`:`outside your ${urgentWindow}-minute window by ${eta-urgentWindow} min`} · demo estimate`;
$("urgentStatus").classList.toggle("warning",!within);
$("urgentFeatures").innerHTML=s.tags.map(t=>`<span>${t}</span>`).join("");
$("urgentWhy").textContent=reason(s);
$("communityGrid").innerHTML=`
<div class="report"><span>Grass</span><strong>${s.reports.grass}</strong></div>
<div class="report"><span>Dog traffic</span><strong>${s.reports.traffic}</strong></div>
<div class="report"><span>Cleanliness</span><strong>${s.reports.clean}</strong></div>`;
$("lastVerified").textContent="Prototype community layer · "+s.verified;
$("urgentConfidence").innerHTML=prov(s);
$("urgentCaveat").textContent=s.caveat;
}
function openUrgentDemo(){
// Avoid switching screens while the existing route request is in flight.
if($("planBtn").disabled||$("planner").classList.contains("hidden")) return;
dogName=$("dog").value.trim()||"Your dog";
lifeStage=$("age").value;
maxDetour=Number($("detour").value);
avoidRelief=$("avoidRelief").checked;
document.querySelectorAll(".chip").forEach(b=>{preferences[b.dataset.pref]=b.classList.contains("active")});
urgentWindow=30;
document.querySelectorAll(".time-pill").forEach(b=>b.classList.toggle("active",b.dataset.window==="30"));
renderUrgent(bestForWindow(30));
syncSelectionState();
$("planner").classList.add("hidden");$("urgent").classList.remove("hidden");scrollTo(0,0)
}
$("plannerDemo").onclick=()=>openUrgentDemo();
$("urgentBack").onclick=()=>{$("urgent").classList.add("hidden");$("planner").classList.remove("hidden");scrollTo(0,0)};
$("navBtn").onclick=()=>window.open(navUrl(current),"_blank");
document.querySelectorAll("[data-alt]").forEach(b=>b.onclick=()=>{
const rs=urgentRank(urgentWindow);
let s;
if(b.dataset.alt==="best") s=rs[0];
if(b.dataset.alt==="fastest") s=[...rs].sort((a,b)=>urgentEta(a)-urgentEta(b)||a.detour-b.detour)[0];
if(b.dataset.alt==="grass") s=[...rs].sort((a,b)=>b.grass-a.grass||b.match-a.match)[0];
if(b.dataset.alt==="restrooms") s=[...rs].sort((a,b)=>b.restrooms-a.restrooms||b.match-a.match)[0];
renderUrgent(s||bestForWindow(urgentWindow));
syncSelectionState(b.dataset.alt);
});