Add Porchfest page at /porchfest/

Already live on the server via rsync; bringing the repo in sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric WagonerandClaude Fable 5 committed 2026-11-10 20:02:22 -05:00
1 parent 1b9e6e32d8
commit 0e9a90a302
2 files changed
+638

No files matched your search

+638
View File
@@ -0,0 +1,638 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Athens Porchfest 2026 Map</title>
<meta name="description" content="Interactive map of all 237 Historic Athens Porchfest sets on Sunday, Oct 18, 1–7pm. Filter by hour and genre, star your bands, and plan a walking route with trolley stops.">
<link rel="canonical" href="https://blog.kestrelsnest.social/porchfest/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Kestrel's Nest">
<meta property="og:title" content="Athens Porchfest 2026 Map">
<meta property="og:description" content="237 sets on 193 porches, Sunday Oct 18, 1–7pm. Map it, star your bands, walk your route.">
<meta property="og:url" content="https://blog.kestrelsnest.social/porchfest/">
<meta property="og:image" content="https://blog.kestrelsnest.social/porchfest/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Night map of Athens neighborhoods with glowing porch dots and the title Athens Porchfest">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Athens Porchfest 2026 Map">
<meta name="twitter:description" content="237 sets on 193 porches, Sunday Oct 18, 1–7pm. Map it, star your bands, walk your route.">
<meta name="twitter:image" content="https://blog.kestrelsnest.social/porchfest/og.png">
<meta name="theme-color" content="#eef1ea" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#141a1f" media="(prefers-color-scheme: dark)">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%23141a1f'/%3E%3Ccircle cx='16' cy='16' r='8' fill='%23ffc23d'/%3E%3C/svg%3E">
<style>:root{color-scheme:light;padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}body{margin:0;font:14px system-ui,-apple-system,sans-serif}img{max-width:100%}[hidden]{display:none!important}</style>
<script data-goatcounter="https://kestrelsnest.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
<meta name="claude:viewport-fit-sides" content="cover">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Young+Serif&family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400&family=IBM+Plex+Mono:wght@500;600&display=swap">
<style>
/* Layout: one-screen app. Hand-drawn street map of the Prince Ave corridor fills the left; a schedule rail on the right (stacked below the map on phones). Porch lights glow when a band is on. */
:root{
--paper:#eef1ea; --land:#e3e8dc; --street:#ffffff; --street-edge:#c9d0c1; --major:#fbf3dc; --major-edge:#d8c79a; --rail:#9aa492;
--ink:#1f2a24; --muted:#5b675f; --line:#d3d9cc; --panel:#f7f8f4; --chip:#e6eae0;
--glow:#e7a91a; --glow-soft:rgba(231,169,26,.28); --off:#a9b2a6; --route:#7a3fb0; --mark:#1f2a24; --mark-fg:#ffffff; --mark-note:#f1c04a; --trolley:#1f4fae; --trolley-fg:#ffffff; --parade:#d6336c; --parade-fg:#ffffff; --parade-soft:rgba(214,51,108,.12); --star:#c4581c;
--g-rock:#c2412d; --g-indie:#7a52c7; --g-folk:#3f8a4a; --g-jazz:#2f6fb5; --g-elec:#0f8f8f; --g-other:#8a6a3c;
--f-display:"Young Serif",Georgia,"Times New Roman",serif;
--f-body:"Atkinson Hyperlegible",system-ui,-apple-system,"Segoe UI",sans-serif;
--f-mono:"IBM Plex Mono",ui-monospace,Menlo,Consolas,monospace;
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
--paper:#141a1f; --land:#18201f; --street:#2a3436; --street-edge:#222b2d; --major:#3b3a2c; --major-edge:#2e2d22; --rail:#4a5552;
--ink:#e8ede6; --muted:#9aa7a0; --line:#2a3335; --panel:#11171b; --chip:#1f282a;
--glow:#ffc23d; --glow-soft:rgba(255,194,61,.32); --off:#4d5a58; --route:#c79bff; --star:#ff8a4c; --mark:#f3eedf; --mark-fg:#141a1f; --mark-note:#9a5f00; --trolley:#8fb0ff; --trolley-fg:#0d1a33; --parade:#ff7eb0; --parade-fg:#2a0614; --parade-soft:rgba(255,126,176,.14);
--g-rock:#ff7a63; --g-indie:#b39bff; --g-folk:#7fd18a; --g-jazz:#7fb3ff; --g-elec:#4fd6cf; --g-other:#d9b37a;
color-scheme:dark}}
:root[data-theme="dark"]{
--paper:#141a1f; --land:#18201f; --street:#2a3436; --street-edge:#222b2d; --major:#3b3a2c; --major-edge:#2e2d22; --rail:#4a5552;
--ink:#e8ede6; --muted:#9aa7a0; --line:#2a3335; --panel:#11171b; --chip:#1f282a;
--glow:#ffc23d; --glow-soft:rgba(255,194,61,.32); --off:#4d5a58; --route:#c79bff; --star:#ff8a4c; --mark:#f3eedf; --mark-fg:#141a1f; --mark-note:#9a5f00; --trolley:#8fb0ff; --trolley-fg:#0d1a33; --parade:#ff7eb0; --parade-fg:#2a0614; --parade-soft:rgba(255,126,176,.14);
--g-rock:#ff7a63; --g-indie:#b39bff; --g-folk:#7fd18a; --g-jazz:#7fb3ff; --g-elec:#4fd6cf; --g-other:#d9b37a;
color-scheme:dark}
html,body{height:100%}
body{background:var(--panel);color:var(--ink);font-family:var(--f-body);font-size:15px;line-height:1.45}
*{box-sizing:border-box}
button,input,select{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--glow);outline-offset:2px}
.app{display:grid;grid-template-columns:minmax(0,1fr) 420px;height:100%}
.mapwrap{position:relative;background:var(--paper);overflow:hidden;touch-action:none;min-width:0}
#map{width:100%;height:100%;display:block;cursor:grab;user-select:none}
#map.drag{cursor:grabbing}
.st{fill:none;stroke-linecap:round;stroke-linejoin:round;vector-effect:non-scaling-stroke}
.st.c{stroke:var(--street);stroke-width:3}
.st.b{stroke:var(--street);stroke-width:5}
.st.a{stroke:var(--major);stroke-width:7}
.st.r{stroke:var(--rail);stroke-width:1.5;stroke-dasharray:5 4}
.case.c{stroke:var(--street-edge);stroke-width:5}
.case.b{stroke:var(--street-edge);stroke-width:7}
.case.a{stroke:var(--major-edge);stroke-width:9}
.lbl{fill:var(--muted);font-family:var(--f-body);paint-order:stroke;stroke:var(--paper);stroke-width:3px;stroke-linejoin:round;letter-spacing:.02em;pointer-events:none}
.hood{fill:var(--ink);opacity:.13;font-family:var(--f-display);text-anchor:middle;pointer-events:none;letter-spacing:.04em}
.pin{cursor:pointer}
.pin .halo{fill:var(--glow-soft)}
.pin .dot{stroke:var(--panel)}
.pin.off .dot{fill:var(--off)}
.pin.sel .ring{stroke:var(--ink);fill:none}
.pin .starring{fill:none;stroke:var(--star)}
#route{fill:none;stroke:var(--route);stroke-dasharray:6 5;vector-effect:non-scaling-stroke;stroke-width:3;opacity:.85}
.routeNum{fill:var(--route);font-family:var(--f-mono);font-weight:600;text-anchor:middle;pointer-events:none;paint-order:stroke;stroke:var(--paper);stroke-linejoin:round}
#trolleyRoute{fill:none;stroke:var(--trolley);stroke-width:3;stroke-dasharray:1 6;stroke-linecap:round;vector-effect:non-scaling-stroke;opacity:.7}
.tstop{cursor:pointer}
.tstop rect{fill:var(--trolley);stroke:var(--panel);stroke-width:2}
.tstop text{fill:var(--trolley-fg);font-family:var(--f-mono);font-weight:600;font-size:12px;text-anchor:middle}
.tstop.sel rect{stroke:var(--ink);stroke-width:3}
.tline{display:flex;gap:8px;align-items:center;margin-top:8px;font-size:14px}
.tnum{display:inline-grid;place-items:center;min-width:22px;height:22px;border-radius:5px;background:var(--trolley);color:var(--trolley-fg);font-family:var(--f-mono);font-weight:600;font-size:12px;flex:none}
.tbtn{border:0;background:none;padding:0;cursor:pointer;color:inherit;text-decoration:underline;text-align:left}
.thint{font-size:12px;color:var(--trolley);padding:0 0 4px 21px}
.stopnav{display:flex;justify-content:space-between;gap:10px;margin:12px 0 4px;font-size:14px}
.parade{fill:none;stroke:var(--parade);stroke-width:12;stroke-linecap:round;stroke-linejoin:round;opacity:.35;vector-effect:non-scaling-stroke;cursor:pointer}
.parade.on{opacity:.65}
.parrow{fill:none;stroke:var(--parade);stroke-width:4;stroke-linecap:round;stroke-linejoin:round;pointer-events:none}
.mark .call{display:none}.mark.open .call{display:inline}
.mark .bg{fill:var(--mark);stroke:var(--panel);stroke-width:2}.mark .ic{fill:var(--mark-fg)}.mark .icbg{fill:var(--mark)}.mark .icstroke{stroke:var(--mark-fg);stroke-width:1.4}
.lmkcard{margin-top:10px;background:var(--chip);border-radius:10px;padding:10px 12px;display:grid;gap:4px}
.lmkcard b{font-size:15px}
.lmkcard .lw{font-family:var(--f-mono);font-size:12px;font-weight:600;color:var(--muted)}
.lmkcard p{margin:0;font-size:14px;line-height:1.5}
.lmkcard .mer{font-size:13px;color:var(--muted)}
.mark .stem{stroke:var(--mark);stroke-width:2}
.mark.pm .bg{fill:var(--parade)}.mark.pm .ic{fill:var(--parade-fg)}.mark.pm .icbg{fill:var(--parade)}
.mark .chev{font-size:18px}
.mark.pm rect{fill:var(--parade)}.mark.pm line{stroke:var(--parade)}.mark.pm .mn{fill:var(--parade-fg)}.mark.pm .mt{fill:var(--parade-fg);opacity:.85}
.evrow{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:center;width:100%;text-align:left;border:1px solid var(--parade);background:var(--parade-soft);border-radius:10px;padding:10px 12px;margin:10px 0 2px;cursor:pointer}
.evrow .evdot{width:11px;height:11px;border-radius:3px;background:var(--parade)}
.evrow .meta{display:block;font-size:13px;color:var(--muted)}
.evrow .t{font-family:var(--f-mono);font-weight:600;color:var(--ink)}
.mark{cursor:pointer}
.mark rect{fill:var(--mark)}
.mark line{stroke:var(--mark);stroke-width:2}
.mark .mn{fill:var(--mark-fg);font-family:var(--f-body);font-weight:700;font-size:13px}
.mark .mt{fill:var(--mark-note);font-family:var(--f-mono);font-weight:600;font-size:11px;letter-spacing:.06em;text-transform:uppercase}
.lmk{display:inline-block;margin-top:8px;background:var(--mark);color:var(--mark-fg);border-radius:6px;padding:3px 9px;font-weight:700;font-size:13px}
.lmk span{color:var(--mark-note);font-family:var(--f-mono);text-transform:uppercase;letter-spacing:.06em;font-size:11px;margin-left:6px}
.zoom{position:absolute;right:calc(12px + env(safe-area-inset-right,0px));bottom:12px;display:flex;flex-direction:column;gap:6px}
.zoom button{width:38px;height:38px;border-radius:8px;border:1px solid var(--line);background:var(--panel);font-size:18px;cursor:pointer;display:grid;place-items:center}
.legend{position:absolute;left:calc(12px + env(safe-area-inset-left,0px));bottom:12px;background:color-mix(in srgb,var(--panel) 88%,transparent);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:12px;display:grid;grid-template-columns:repeat(2,auto);gap:3px 14px;backdrop-filter:blur(4px)}
.legend span{display:flex;align-items:center;gap:6px;white-space:nowrap}
.sw{width:10px;height:10px;border-radius:50%;display:inline-block;flex:none}
.helpbtn{position:absolute;top:12px;right:calc(12px + env(safe-area-inset-right,0px));width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:var(--panel);font-family:var(--f-display);font-size:19px;cursor:pointer;display:grid;place-items:center;z-index:2}
.osm{position:absolute;bottom:3px;left:50%;transform:translateX(-50%);font-size:10px;color:var(--muted);text-decoration:none;opacity:.8;white-space:nowrap}
.help h3{font-family:var(--f-display);font-weight:400;font-size:18px;margin:20px 0 4px;letter-spacing:0;text-transform:none;color:var(--ink)}
.help p,.help li{font-size:15px;line-height:1.55;max-width:62ch}
.help p{margin:6px 0}
.help ul{margin:6px 0;padding-left:20px;display:grid;gap:6px}
.help .ico{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:50%;vertical-align:-4px;margin-right:2px}
.help .lede{font-size:16px}
.maptitle{position:absolute;left:calc(14px + env(safe-area-inset-left,0px));top:12px;pointer-events:none}
.maptitle h1{font-family:var(--f-display);font-weight:400;font-size:clamp(20px,2.4vw,30px);margin:0;line-height:1.05;text-wrap:balance}
.maptitle p{margin:2px 0 0;color:var(--muted);font-size:13px}
.side{display:flex;flex-direction:column;min-height:0;border-left:1px solid var(--line);background:var(--panel)}
.controls{padding-block:4px 10px;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));display:grid;gap:10px;border-bottom:1px solid var(--line)}
.tabs{display:flex;gap:4px;background:var(--chip);border-radius:10px;padding:3px}
.tabs button{flex:1;border:0;background:transparent;padding:7px 8px;border-radius:8px;cursor:pointer;font-weight:700;font-size:14px;color:var(--muted)}
.tabs button[aria-pressed="true"]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px rgba(0,0,0,.12)}
.tabs .count{font-family:var(--f-mono);font-size:12px;margin-left:4px;opacity:.75}
.hours{display:grid;grid-template-columns:repeat(8,1fr);gap:4px}
.hours button{border:1px solid var(--line);background:transparent;border-radius:8px;padding:6px 0 5px;cursor:pointer;font-family:var(--f-mono);font-weight:600;font-size:14px;line-height:1.1;position:relative}
.hours button small{display:block;font-family:var(--f-body);font-weight:400;font-size:10px;color:var(--muted)}
.hours button[aria-pressed="true"]{background:var(--ink);color:var(--panel);border-color:var(--ink)}
.hours button[aria-pressed="true"] small{color:inherit;opacity:.75}
.hours .now::after{content:"";position:absolute;top:4px;right:4px;width:7px;height:7px;border-radius:50%;background:var(--glow);box-shadow:0 0 0 3px var(--glow-soft)}
.row{display:flex;gap:8px;flex-wrap:wrap}
.search{flex:1 1 180px;min-width:0;border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:9px 10px;font-size:16px}
.bar{display:grid;gap:10px;padding-block:14px 10px;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));background:var(--panel)}
.filters{display:grid;gap:10px;justify-items:start}
.filtbtn{display:none;border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:0 12px;font-weight:700;cursor:pointer;align-items:center;gap:6px;min-height:42px}
.filtbtn span:not(:empty){font-family:var(--f-mono);font-size:12px;background:var(--ink);color:var(--panel);border-radius:999px;padding:1px 7px}
.filtbtn[aria-expanded="true"]{background:var(--chip)}
.mapsize{display:none;position:absolute;left:calc(12px + env(safe-area-inset-left,0px));bottom:12px;border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:0 12px;min-height:38px;font-size:13px;font-weight:700;cursor:pointer}
#me .acc{fill:var(--trolley);opacity:.12}
#me .dot{fill:var(--trolley);stroke:var(--panel)}
#zloc[aria-pressed="true"]{background:var(--trolley);color:var(--trolley-fg);border-color:var(--trolley)}
.near{font-family:var(--f-mono);font-weight:600;color:var(--trolley)}
.toast{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(16px + env(safe-area-inset-bottom,0px));background:var(--ink);color:var(--panel);padding:10px 14px;border-radius:10px;font-size:14px;z-index:20;max-width:calc(100% - 32px);box-shadow:0 4px 16px rgba(0,0,0,.25)}
select{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:8px;font-size:16px;max-width:100%}
.chips{display:flex;gap:5px;flex-wrap:wrap}
.chip{border:1px solid var(--line);background:transparent;border-radius:999px;padding:3px 10px 3px 8px;font-size:13px;cursor:pointer;display:flex;align-items:center;gap:6px}
.chip[aria-pressed="false"]{opacity:.45}
.chip[aria-pressed="false"] .sw{background:transparent!important;box-shadow:inset 0 0 0 1.5px var(--muted)}
.status{font-size:13px;color:var(--muted);display:flex;justify-content:space-between;gap:8px;align-items:baseline}
.status b{color:var(--ink);font-family:var(--f-mono)}
.linkbtn{border:0;background:none;padding:0;color:var(--muted);text-decoration:underline;cursor:pointer;font-size:13px}
.list{overflow:auto;flex:1;min-height:0;padding-block:6px 24px;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px))}
.group{margin-top:12px}
.group h3{font-family:var(--f-mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:0 0 4px;font-weight:600;display:flex;justify-content:space-between}
.act{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:10px;align-items:start;padding:9px 6px;border-radius:8px;cursor:pointer;border-top:1px solid var(--line)}
.act:hover,.act.hl{background:var(--chip)}
.act .sw{margin-top:6px;width:11px;height:11px}
.act .nm{font-weight:700;line-height:1.25}
.act .meta{font-size:13px;color:var(--muted)}
.act[aria-expanded="true"]{background:var(--chip)}
.det{margin-top:8px;display:grid;gap:6px;cursor:auto}
.det .d{font-size:14px;line-height:1.5}
.det .dm{font-size:13px;color:var(--muted)}
.det .lk{display:flex;gap:14px;flex-wrap:wrap;font-size:13px}
.rq{border-left:3px solid var(--glow);padding:2px 0 2px 10px;display:grid;gap:3px}
.rqh{font-size:12px;color:var(--muted)}
.rqh a{color:inherit}
.rqt{font-size:14px;line-height:1.5}
.rtag{font-family:var(--f-mono);font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ink);background:var(--glow-soft);border-radius:4px;padding:1px 5px;vertical-align:2px;white-space:nowrap}
.act .t{font-family:var(--f-mono);font-weight:600;color:var(--ink)}
.starbtn{border:0;background:none;cursor:pointer;font-size:22px;line-height:1;color:var(--off);min-width:44px;min-height:44px;margin:-10px -10px -10px 0;display:grid;place-items:center;border-radius:50%}
.starbtn[aria-pressed="true"]{color:var(--star)}
.empty{color:var(--muted);padding:24px 4px;text-align:center}
.empty strong{display:block;color:var(--ink);font-family:var(--f-display);font-weight:400;font-size:20px;margin-bottom:6px}
.porch{padding:14px 0 6px}
.porch .back{margin-bottom:10px}
.porch h2{font-family:var(--f-display);font-weight:400;font-size:24px;margin:0;line-height:1.15;text-wrap:balance}
.porch .sub{color:var(--muted);font-size:14px;margin-top:2px}
.porch .links{display:flex;gap:12px;flex-wrap:wrap;margin:10px 0 4px;font-size:14px}
a{color:inherit}
.slot{display:grid;grid-template-columns:44px minmax(0,1fr) auto;gap:10px;padding:12px 0;border-top:1px solid var(--line)}
.slot.cur{background:linear-gradient(90deg,var(--glow-soft),transparent 70%);margin-inline:-8px;padding-inline:8px;border-radius:6px}
.slot .t{font-family:var(--f-mono);font-weight:600;font-size:15px}
.slot .nm{font-weight:700}
.slot .g{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:6px}
.slot .d{font-size:14px;margin-top:4px;color:var(--ink);opacity:.85}
.slot .lk{display:flex;gap:12px;font-size:13px;margin-top:5px;flex-wrap:wrap}
.walk{font-size:12px;color:var(--route);font-family:var(--f-mono);padding:2px 0 2px 21px;position:relative}
.walk::before{content:"";position:absolute;left:5px;top:-6px;bottom:-6px;border-left:2px dashed var(--route);opacity:.6}
.warn{color:var(--g-rock);font-size:12px;font-weight:700}
.plancard{border:1px dashed var(--line);border-radius:10px;padding:12px;margin-top:12px;font-size:14px;color:var(--muted)}
@media (max-width:860px){
.app{grid-template-columns:1fr;grid-template-rows:auto auto;height:auto;--mapH:max(220px,40svh)}
.app.mapsmall{--mapH:150px}
.mapwrap{height:var(--mapH);position:sticky;top:env(safe-area-inset-top,0px);z-index:3}
.bar{position:sticky;top:calc(env(safe-area-inset-top,0px) + var(--mapH));z-index:3;padding-block:10px 8px;border-bottom:1px solid var(--line);box-shadow:0 6px 10px -10px rgba(0,0,0,.4)}
.controls{padding-block:10px;border-bottom:0}
.filtbtn{display:inline-flex}
.filters{display:none}
.filters.open{display:grid}
.mapsize{display:block}
.app.mapsmall .maptitle p,.app.mapsmall .maptitle h1{display:none}
.app.mapsmall .zoom button{width:34px;height:34px}
.app.mapsmall #zin,.app.mapsmall #zout{display:none}
.hours button{padding:8px 0 6px}
.act{padding:11px 6px}
.side{border-left:0;border-top:1px solid var(--line)}
.list{overflow:visible}
.legend{display:none}
body::before{content:"";position:fixed;inset:0 0 auto;height:env(safe-area-inset-top,0px);z-index:10;background:var(--paper)}
}
@media (max-width:420px){.hours button{font-size:13px}.maptitle p{display:none}}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
</head>
<body>
<div class="app">
<div class="mapwrap">
<svg id="map" role="img" aria-label="Map of Porchfest porches in Cobbham, Boulevard, Normaltown and nearby neighborhoods">
<g id="world">
<g id="cases"></g><g id="streets"></g><g id="labels"></g><g id="hoods"></g>
<path id="route" d=""></path><g id="routeNums"></g>
<g id="paradeG"></g><path id="trolleyRoute" d=""></path><g id="pins"></g><g id="marks"></g><g id="tstops"></g><g id="me"></g>
</g>
</svg>
<button class="helpbtn" id="helpBtn" aria-label="How this map works">?</button>
<a class="osm" href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">© OpenStreetMap</a>
<div class="maptitle"><h1>Athens Porchfest</h1><p>Sun Oct 18 · 1–7pm · lineup as of Oct 17, 4:30pm</p></div>
<div class="legend" id="legend"></div>
<button class="mapsize" id="mapSize" aria-label="Make the map smaller">Smaller map</button>
<div class="zoom">
<button id="zloc" aria-label="Show where I am" title="Show where I am"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="4" fill="currentColor"/><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 1v4M12 19v4M1 12h4M19 12h4" stroke="currentColor" stroke-width="2"/></svg></button>
<button id="zin" aria-label="Zoom in">+</button>
<button id="zout" aria-label="Zoom out">−</button>
<button id="zfit" aria-label="Show all porches" title="Show all porches">⤢</button>
</div>
</div>
<aside class="side">
<div class="bar" id="bar">
<div class="tabs" role="group" aria-label="View">
<button id="tabLineup" aria-pressed="true">Lineup</button>
<button id="tabPlan" aria-pressed="false">My day<span class="count" id="planCount"></span></button>
</div>
<div class="hours" id="hours" role="group" aria-label="Start time"></div>
</div>
<div class="controls">
<div class="row">
<input class="search" id="q" type="search" placeholder="Search bands, hosts, streets…" aria-label="Search" enterkeyhint="search" autocomplete="off">
<button class="filtbtn" id="filtBtn" aria-expanded="false" aria-controls="filters">Filters<span id="filtCount"></span></button>
</div>
<div class="filters" id="filters">
<select id="hood" aria-label="Neighborhood"><option value="">All neighborhoods</option></select>
<div class="chips" id="genres" role="group" aria-label="Genres"></div>
</div>
<div class="status" id="status"></div>
</div>
<div class="list" id="list"></div>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
</aside>
</div>
<script>
const DATA={"porches":[{"a":"380 Meigs Street","h":"Cobbham","hosts":["Adam"],"x":920,"y":290},{"a":"670 Holman Ave","h":"Normaltown","hosts":["Alex Nichols"],"x":-1287,"y":-3},{"a":"165 The Plaza","h":"Reese Hancock","hosts":["Alexandra Larsen"],"x":-84,"y":506},{"a":"399 Buena Vista Ave","h":"Buena Vista Heights","hosts":["Alison & Dan Miller"],"x":-578,"y":-671},{"a":"550 Nantahala Ave","h":"Boulevard","hosts":["Ben Mize, Stage 2","Ben Mize, Stage 1"],"x":99,"y":-599},{"a":"834 Hill Street","h":"Cobbham","hosts":["Bill Overend"],"x":59,"y":383},{"a":"575 N Harris St","h":"Cobbham","hosts":["Bob Allen"],"x":570,"y":166},{"a":"160 Park Ave","h":"Buena Vista Heights","hosts":["Breanna Teague"],"x":-319,"y":-306},{"a":"394 Dubose Ave","h":"Boulevard","hosts":["Bryson Greenwood"],"x":212,"y":-208},{"a":"145 Grady Ave","h":"Boulevard","hosts":["Cameron Norton, Stage 1","Cameron Norton, Stage 2"],"x":686,"y":-99},{"a":"137 Oglethorpe Ave","h":"Normaltown","hosts":["Claire Henry"],"x":-643,"y":-153},{"a":"635 Pulaski St","h":"Pulaski Heights","hosts":["Dave Lawson","Jillian Grant"],"x":1258,"y":-286},{"a":"258 Lyndon Ave","h":"Boulevard","hosts":["durwood pepper and Jim Taflinger"],"x":399,"y":-212},{"a":"120 Lenoir Ave","h":"Buena Vista Heights","hosts":["Eadaoin Walsh"],"x":-152,"y":-333},{"a":"310 Clover St","h":"Normaltown","hosts":["Eric & Valerie Herzig"],"x":-1542,"y":264},{"a":"762 Barber Street","h":"Newtown","hosts":["Erin Johnson"],"x":715,"y":-969},{"a":"589 Meigs St","h":"Cobbham","hosts":["Finley freeman"],"x":655,"y":378},{"a":"268 W Dougherty Street","h":"Pulaski Heights","hosts":["First Christian Church Athens"],"x":1346,"y":203},{"a":"239 N Finley St","h":"Reese Hancock","hosts":["Georgia Hartley"],"x":1080,"y":463},{"a":"445 N Milledge Avenue","h":"Cobbham","hosts":["Greg Ducote"],"x":490,"y":359},{"a":"230 Augusta Ave","h":"Newtown","hosts":["Joe Ornstein, Stage 1","Joe Ornstein, Stage 2"],"x":1035,"y":-819},{"a":"153 Boulevard Hts","h":"Buena Vista Heights","hosts":["John Holdridge and Ruth Harman"],"x":-810,"y":-488},{"a":"285 Barber","h":"Boulevard","hosts":["Katie Jarrell"],"x":896,"y":-318},{"a":"170 Lakeview St","h":"Buena Vista Heights","hosts":["kristine kishida"],"x":-235,"y":-647},{"a":"327 Prince Ave","h":"Cobbham","hosts":["Labels Resale Boutique"],"x":1052,"y":175},{"a":"564 Boulevard","h":"Boulevard","hosts":["Lance Porter"],"x":366,"y":-427},{"a":"731 Pulaski Street","h":"Pulaski Heights","hosts":["Lisbeth Wells-Pratt"],"x":1250,"y":-568},{"a":"412 Holman Ave","h":"Normaltown","hosts":["Michael Brewer"],"x":-1231,"y":499},{"a":"179 Virginia Ave","h":"Boulevard","hosts":["Michael Wegner"],"x":584,"y":-171},{"a":"425 Barber Street","h":"Boulevard","hosts":["Normaltown Brewing Co."],"x":823,"y":-550},{"a":"197 Childs St","h":"Pulaski Heights","hosts":["Patrick and Stacey Kubis"],"x":1101,"y":-154},{"a":"553 N Chase St","h":"Cobbham","hosts":["Sarah Mosseri & Noah Archambault"],"x":206,"y":299},{"a":"180 Cleveland Avenue","h":"Newtown","hosts":["Southern Star Studio"],"x":1271,"y":-706},{"a":"255 Holman Ave","h":"Normaltown","hosts":["Stella Turner"],"x":-1020,"y":716},{"a":"175 Tracy St","h":"Newtown","hosts":["Sunroof Coffee","Nighthawks Lounge"],"x":634,"y":-852},{"a":"130 Willow Run","h":"Normaltown","hosts":["Sydney James"],"x":-754,"y":501},{"a":"270 Nacoochee Avenue","h":"Boulevard","hosts":["Tanya Hudson"],"x":-29,"y":-421},{"a":"244 Nacoochee Ave","h":"Boulevard","hosts":["Addison Sanders"],"x":-33,"y":-387},{"a":"675 Pulaski St","h":"Pulaski Heights","hosts":["ATHICA Leathers Building, Stage 1","Revolution Therapy and Yoga (owned by Kate Morrissey Stahl)"],"x":1255,"y":-400},{"a":"473 Atlanta Ave","h":"Newtown","hosts":["Ben McCoy"],"x":1129,"y":-806},{"a":"150 Virginia Ave","h":"Boulevard","hosts":["Bonnie Feagle"],"x":591,"y":-316},{"a":"179 Hiawassee Ave","h":"Buena Vista Heights","hosts":["Brad Stowe","Bradley Stowe"],"x":-185,"y":-379},{"a":"340 Willow Run","h":"Normaltown","hosts":["Caitlin Mertzlufft"],"x":-982,"y":71},{"a":"425 Cleveland Ave","h":"Newtown","hosts":["Christian Haygood"],"x":999,"y":-614},{"a":"770 Barber Street","h":"Newtown","hosts":["Courtney Osterhage"],"x":710,"y":-997},{"a":"123 Chattooga Ave","h":"Boulevard","hosts":["emileigh ireland"],"x":156,"y":-723},{"a":"150 Easy St","h":"Buena Vista Heights","hosts":["Emily Chalfin"],"x":-404,"y":-357},{"a":"100 Prince Ave","h":"Pulaski Heights","hosts":["Farm Burger Athens, Stage 1","Farm Burger Athens, Stage 2"],"x":1233,"y":195},{"a":"232 Glenhaven Ave","h":"Reese Hancock","hosts":["Front Porch"],"x":-53,"y":674},{"a":"549 Sunset Dr","h":"Normaltown","hosts":["Gillian Slee"],"x":-1395,"y":311},{"a":"149 Tibbets Avenue","h":"Buena Vista Heights","hosts":["Good Shepherd Backyard"],"x":-119,"y":-528},{"a":"1022 Prince Avenue","h":"Boulevard","hosts":["Hall Booth Smith. P.C. (Contact - Emma Grace Jarvis)"],"x":108,"y":-14},{"a":"405 Buena Vista Ave","h":"Buena Vista Heights","hosts":["Hardin"],"x":-57Line truncated
const STREETS={"names":["Standard Oil Street","Edwards Drive","West Dougherty Street","North Church Street","New Avenue","Virginia Avenue","Georgia Avenue","Crestview Circle","Savannah Avenue","Willow Run","Foster Road","Jeffries Court","Evans Street","Greenwood Court","Holman Avenue","Cleveland Avenue","Dubose Avenue","Hoyt Street","North Franklin Street","South Church Street","Hills Chapel Street","Oaktree Square","West Hancock Avenue","Bryan Street","Pound Street","Hodgson Drive","Normal Avenue","Barber Street","Campbell Lane","Crawford Street","Gardenia Drive","Chatooga Avenue","East Hancock Avenue","Windsor Court","Nantahala Avenue","Tibbets Avenue","Atlanta Avenue","Glenhaven Avenue","Price Avenue","Waterworks Drive","North Milledge Avenue","Sunset Terrace","Conard Road","Kenny Road","Pulaski Street","South Pope Street","Cobb Street","North Pope Street","Fox Road","South Rocksprings Street","Hill Street","Beulah Avenue","Gloria Street","Forbstein Alley","Childs Street","Bazzelle Avenue","James Street","Lee Street","South Chase Street","Augusta Avenue","Yonah Avenue","Meigs Street","Boulevard Heights","Macon Avenue","College Avenue","Ware Street","Tracy Street","Indale Avenue","Grady Avenue","East Dougherty Street","South Hull Street","Boulevard","North Hull Street","Miller Street","Bowstrom Road","Madison Avenue","Old West Broad Street","Easy Street","Hawthorne Extension","North Finley Street","Minor Street","Walker Street","Hillcrest Avenue","Monroe Street","North Rocksprings Street","Buena Vista Avenue","Hawthorne Avenue","Waverly Court","Gilmer Street","Baldwin Street","South Finley Street","Dearing Street","Westover Drive","Jefferson Place","Oglethorpe Terrace","Sylvan Road","North Lumpkin Street","Seminole Avenue","Talmadge Drive","Strong Street","Millard Avenue","Barrow Street","Reese Street","Willow Street","Old Broad Street","Jefferson Road","Miles Street","Brooklyn Road","Franklin Street","South Billups Street","Cain Street","Prince Avenue","Weir Circle","Satula Avenue","Ruth Drive","Wray Street","Smith Street","Stevens Street","Nacoochee Avenue","East Washington Street","Buck Road","Hendrix Avenue","Plaza Street","Edwards Circle","Best Drive","Prince Ville Lane","Sunset Drive","King Avenue","Judy Circle","North Harris Street","Kesler Street","North Chase Street","West Broad Street","Belvoir Heights","Mathews Avenue","Nantahala Extension","Wynburn Avenue","North Billups Street","Sylvia Circle","Lyndon Avenue","Oneta Street","Clover Street","South Newton Street","Julius Drive","Myrtle Court","Hiawassee Avenue","Elizabeth Street","Pulaski Heights","Waddell Street","Lenoir Avenue","Broadacres Court","South Harris Street","South Lumpkin Street","Donna Street","Park Avenue","West Clayton Street","Hart Avenue","Cohen Street","Lakeview Street","Pineneedle Road","Morton Alley","McGowan Road","State Street","Athens Avenue","Kendene Street","Tibbetts Street","Prince Place","Jefferson Circle","Carter Road","","CSX Abbeville Subdivision","Doctor Martin Luther King Junior Parkway","Helen Street","Athens Line","North Newton Street","Oglethorpe Avenue","Madison Street","Old Epps Bridge Road","Morningside Drive","Brookside Avenue","Summerbrook Circle","South Milledge Avenue","Pauldoe Court","Ruth Street","East Broad Street","West Washington Street","North Jackson Street","East Clayton Street","Paris Street"],"ways":[{"c":"c","n":0,"p":[1326,-716,40,74,10,47,-4,18,-7,4,-24,0]},{"c":"c","n":1,"p":[-978,175,-77,21,-214,14,-402,43]},{"c":"b","n":2,"p":[1581,146,-232,65,-49,4]},{"c":"c","n":3,"p":[865,711,-142,-491]},{"c":"c","n":4,"p":[-5,-690,-236,-33]},{"c":"c","n":5,"p":[570,-107,32,-283]},{"c":"c","n":6,"p":[-525,-148,-226,191,-82,5]},{"c":"c","n":7,"p":[-685,451,61,-67,5,-34,-97,-174]},{"c":"c","n":8,"p":[949,-606,-80,-258]},{"c":"c","n":9,"p":[-693,585,-22,-20,-98,-116,-109,-103,-27,-38,-40,-196,5,-187]},{"c":"c","n":10,"p":[-1013,-73,10,-245]},{"c":"c","n":11,"p":[271,506,-48,13]},{"c":"c","n":12,"p":[-604,1133,7,154,-18,146,-12,193,5,7,56,31,50,13]},{"c":"c","n":13,"p":[-1156,-65,13,185]},{"c":"c","n":14,"p":[-740,911,-78,-59,-77,-68,-24,-10,-66,-44,-130,-55,-118,-159,-63,-573]},{"c":"b","n":15,"p":[1485,-689,-130,-32,-21,3,-428,126,-36,-4,-68,-31]},{"c":"c","n":15,"p":[833,-528,48,-16,12,-13,13,-35]},{"c":"c","n":16,"p":[685,-76,-67,-12,-173,-68,-269,-47]},{"c":"c","n":17,"p":[1416,-203,71,18,101,52,20,1,29,-8,217,-93]},{"c":"c","n":18,"p":[449,700,-33,-124,5,-15,-151,-527,2,-13]},{"c":"c","n":19,"p":[865,711,171,604]},{"c":"c","n":20,"p":[-401,1192,104,-10,92,-33]},{"c":"c","n":21,"p":[-1328,-104,-61,7,-21,-104]},{"c":"c","n":21,"p":[-1342,-216,-115,27]},{"c":"b","n":22,"p":[1605,238,-266,69,-19,12,-258,68,-762,222,-244,78,-43,26,-327,330]},{"c":"c","n":23,"p":[245,-884,-65,-7,-163,67,-29,-4,-11,-7]},{"c":"c","n":24,"p":[-962,-489,180,-220,63,-70,14,-7]},{"c":"c","n":25,"p":[-369,1075,-56,-189,-41,-57,-126,-111]},{"c":"c","n":26,"p":[-845,-84,28,343]},{"c":"b","n":27,"p":[1001,164,-17,-181,-97,-344,-1Line truncated
const FAMILIES=[
{k:'rock',label:'Rock',genres:['Rock']},
{k:'loud',label:'Metal / punk',genres:['Metal/punk'],color:'rock'},
{k:'indie',label:'Indie & pop',genres:['Indie','Pop']},
{k:'folk',label:'Folk, country, songwriter',genres:['Folk','Country','Acoustic/singer-songwriter']},
{k:'jazz',label:'Jazz & classical',genres:['Jazz','Classical']},
{k:'elec',label:'Electronic & hip-hop',genres:['Electronic/experimental','Hip-hop/rap']},
{k:'other',label:'Everything else',genres:['Other']},
];
const famOf={};FAMILIES.forEach(f=>f.genres.forEach(g=>famOf[g]=f));
function famColor(f){return `var(--g-${f.k==='loud'?'rock':f.k})`}
function gColor(g){return famColor(famOf[g]||FAMILIES[6])}
const HOURS=[1,2,3,4,5,6,7];
const P=DATA.porches, A=DATA.acts;
A.forEach((a,i)=>{a.id=i;a.porch=P[a.p];(P[a.p].acts||(P[a.p].acts=[])).push(a)});
P.forEach((p,i)=>{p.id=i;p.acts.sort((a,b)=>a.t-b.t)});
// ---------- state ----------
const store={get(k,d){try{const v=localStorage.getItem(k);return v?JSON.parse(v):d}catch(e){return d}},set(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}}};
const nowInfo=(()=>{const d=new Date();const fmt=new Intl.DateTimeFormat('en-US',{timeZone:'America/New_York',year:'numeric',month:'2-digit',day:'2-digit',hour:'numeric',minute:'numeric',hour12:false});const parts=Object.fromEntries(fmt.formatToParts(d).map(x=>[x.type,x.value]));const isDay=parts.year==='2026'&&parts.month==='10'&&parts.day==='18';const h=+parts.hour%24,m=+parts.minute;let hr=null;if(isDay){if(h===12&&m>=30)hr=1;else if(h>=13&&h<=19){hr=h-12;if(m>=45&&hr<7)hr++}}return {isDay,hour:hr}})();
const S={hour:nowInfo.hour||1,view:'lineup',q:'',hood:'',fams:new Set(FAMILIES.map(f=>f.k)),porch:null,stars:new Set(store.get('pf26-stars',[])),hl:null};
function saveStars(){store.set('pf26-stars',[...S.stars])}
// ---------- map geometry ----------
const svg=document.getElementById('map'), world=document.getElementById('world');
const names=STREETS.names;
const ways=STREETS.ways.map(w=>{const pts=[];let x=w.p[0],y=w.p[1];pts.push([x,y]);for(let i=2;i<w.p.length;i+=2){x+=w.p[i];y+=w.p[i+1];pts.push([x,y])}return {c:w.c,n:names[w.n],pts}});
const NS='http://www.w3.org/2000/svg';
function el(tag,attrs,parent){const e=document.createElementNS(NS,tag);for(const k in attrs)e.setAttribute(k,attrs[k]);parent&&parent.appendChild(e);return e}
function pathD(pts){return 'M'+pts.map(p=>p.join(',')).join('L')}
const order={r:0,c:1,b:2,a:3};
const sorted=[...ways].sort((a,b)=>order[a.c]-order[b.c]);
const gCases=document.getElementById('cases'),gStreets=document.getElementById('streets'),gLabels=document.getElementById('labels');
sorted.forEach(w=>{const d=pathD(w.pts);if(w.c!=='r')el('path',{d,class:'st case '+w.c},gCases);el('path',{d,class:'st '+w.c},gStreets)});
// street labels: longest segment per name
const len=pts=>{let s=0;for(let i=1;i<pts.length;i++)s+=Math.hypot(pts[i][0]-pts[i-1][0],pts[i][1]-pts[i-1][1]);return s};
const bestBy={};ways.forEach(w=>{if(!w.n||w.c==='r')return;const L=len(w.pts);if(!bestBy[w.n]||bestBy[w.n].L<L)bestBy[w.n]={w,L}});
const shortName=n=>n.replace(/^North /,'N ').replace(/^South /,'S ').replace(/^West /,'W ').replace(/^East /,'E ').replace(/ Avenue$/,' Ave').replace(/ Street$/,' St').replace(/ Drive$/,' Dr').replace(/Doctor Martin Luther King Junior Parkway/,'MLK Pkwy');
const labelEls=[];
Object.values(bestBy).forEach(({w,L},i)=>{if(L<180)return;let pts=w.pts;if(pts[pts.length-1][0]<pts[0][0])pts=[...pts].reverse();const id='sp'+i;el('path',{id,d:pathD(pts),fill:'none',stroke:'none'},gLabels);const t=el('text',{class:'lbl'},gLabels);const tp=el('textPath',{href:'#'+id,startOffset:'50%','text-anchor':'middle'},t);tp.textContent=shortName(w.n);labelEls.push({t,c:w.c,L})});
// neighborhood labels
const hoodEls=[];const hoods={};P.forEach(p=>{(hoods[p.h]||(hoods[p.h]={x:0,y:0,n:0}));hoods[p.h].x+=p.x;hoods[p.h].y+=p.y;hoods[p.h].n++});
const hoodNudge={'Cobbham':[0,60],'Pulaski Heights':[60,0],'Reese Hancock':[0,90],'Newtown':[60,-40],'Boulevard':[0,30],'Normaltown':[-40,40],'Buena Vista Heights':[0,-60]};
Object.entries(hoods).forEach(([h,v])=>{const n=hoodNudge[h]||[0,0];const t=el('text',{class:'hood',x:v.x/v.n+n[0],y:v.y/v.n+n[1]},document.getElementById('hoods'));t.textContent=h;hoodEls.push(t)});
const hoodSel=document.getElementById('hood');Object.keys(hoods).sort().forEach(h=>{const o=document.createElement('option');o.value=h;o.textContent=`${h} (${P.filter(p=>p.h===h).length} porches)`;hoodSel.appendChild(o)});
// view transform
const B={x0:Math.min(...P.map(p=>p.x))-120,x1:Math.max(...P.map(p=>p.x))+120,y0:Math.min(...P.map(p=>p.y))-170,y1:Math.max(...P.map(p=>p.y))+120};
let V={k:1,tx:0,ty:0};
function size(){const r=svg.getBoundingClientRect();return {w:r.width,h:r.height}}
function fitTo(b,pad=30){const {w,h}=size();if(!w||!h)return;const k=Math.min((w-pad*2)/(b.x1-b.x0),(h-pad*2)/(b.y1-b.y0));V.k=k;V.tx=w/2-k*(b.x0+b.x1)/2;V.ty=h/2-k*(b.y0+b.y1)/2;apply()}
function clampK(k){const {w,h}=size();const kmin=Math.min(w/(B.x1-B.x0),h/(B.y1-B.y0))*0.6;return Math.max(kmin,Math.min(k,kmin*14))}
function zoomAt(f,cx,cy){const k2=clampK(V.k*f);f=k2/V.k;V.tx=cx-(cx-V.tx)*f;V.ty=cy-(cy-V.ty)*f;V.k=k2;apply()}
function apply(){world.setAttribute('transform',`translate(${V.tx},${V.ty}) scale(${V.k})`);const k=V.k;
const fs=11.5/k;labelEls.forEach(l=>{const show=l.c==='a'||l.c==='b'?k>0.18:k>0.42&&l.L>260;l.t.style.display=show?'':'none';l.t.setAttribute('font-size',fs);l.t.style.strokeWidth=(3/k)+'px'});
hoodEls.forEach(t=>{t.setAttribute('font-size',Math.max(15,Math.min(30,26*Math.sqrt(k/0.25)))/k)});
renderPinsScale()}
// pan / pinch / wheel
const ptrs=new Map();let last=null,moved=false;
let down=null;
svg.addEventListener('pointerdown',e=>{ptrs.set(e.pointerId,{x:e.clientX,y:e.clientY});down={x:e.clientX,y:e.clientY};moved=false;if(ptrs.size>1)moved=true});
svg.addEventListener('pointermove',e=>{if(!ptrs.has(e.pointerId)||!down)return;if(!moved&&Math.hypot(e.clientX-down.x,e.clientY-down.y)>5){moved=true}if(moved&&!svg.hasPointerCapture(e.pointerId)){try{svg.setPointerCapture(e.pointerId)}catch(_){}svg.classList.add('drag')}},true);
svg.addEventListener('pointermove',e=>{if(!ptrs.has(e.pointerId))return;const prev=ptrs.get(e.pointerId);const cur={x:e.clientX,y:e.clientY};ptrs.set(e.pointerId,cur);
if(ptrs.size===1){const dx=cur.x-prev.x,dy=cur.y-prev.y;if(moved&&Math.abs(dx)+Math.abs(dy)>0){V.tx+=dx;V.ty+=dy;apply()}}
else if(ptrs.size===2){const [a,b]=[...ptrs.values()];const other=[...ptrs.entries()].find(([id])=>id!==e.pointerId)[1];const d1=Math.hypot(prev.x-other.x,prev.y-other.y),d2=Math.hypot(a.x-b.x,a.y-b.y);const r=svg.getBoundingClientRect();if(d1>0)zoomAt(d2/d1,(a.x+b.x)/2-r.left,(a.y+b.y)/2-r.top);moved=true}});
function endPtr(e){ptrs.delete(e.pointerId);if(!ptrs.size)svg.classList.remove('drag')}
svg.addEventListener('pointerup',endPtr);svg.addEventListener('pointercancel',endPtr);
svg.addEventListener('wheel',e=>{e.preventDefault();const r=svg.getBoundingClientRect();zoomAt(Math.exp(-e.deltaY*0.0018),e.clientX-r.left,e.clientY-r.top)},{passive:false});
document.getElementById('zin').onclick=()=>{const {w,h}=size();zoomAt(1.5,w/2,h/2)};
document.getElementById('zout').onclick=()=>{const {w,h}=size();zoomAt(1/1.5,w/2,h/2)};
document.getElementById('zfit').onclick=()=>fitTo(B);
function panTo(p,minK){const {w,h}=size();if(minK&&V.k<minK){V.k=clampK(minK)}V.tx=w/2-V.k*p.x;V.ty=h/2-V.k*p.y;apply()}
// ---------- filtering ----------
function matchQ(a){if(!S.q)return true;const q=S.q.toLowerCase();return [a.n,a.g,a.host,a.porch.a,a.porch.h,a.d,...a.porch.hosts].some(s=>s&&s.toLowerCase().includes(q))}
function famOn(a){return S.fams.has((famOf[a.g]||FAMILIES[6]).k)}
function passes(a){return famOn(a)&&matchQ(a)&&(!S.hood||a.porch.h===S.hood)}
function visibleActs(){if(S.view==='plan')return A.filter(a=>S.stars.has(a.id)).sort((x,y)=>x.t-y.t||x.porch.x-y.porch.x);return A.filter(a=>passes(a)&&(S.hour===0||a.t===S.hour))}
// ---------- pins ----------
const LANDMARKS=[
{a:'108 Park Ave',name:'Athentic Brewing',note:'Food truck park',kind:'truck',title:'Food Truck Park',when:'12–7pm',desc:'Local food trucks all afternoon. TH3R4 plays at 3pm and Noise Mountain at 5pm.',merch:'12:30–6pm'},
{a:'425 Barber Street',name:'Normaltown Brewing',note:'Indie South market',kind:'palette',title:'Indie South Vendor Market',when:'12–7pm',desc:'An artisan market with vintage and handmade goods.',merch:'12:30–6:30pm'},
{a:'890 Boulevard Avenue',name:'Good Shepherd Kids Zone',note:'Free · 1–5pm',kind:'kids',title:'Good Shepherd Kids Zone',when:'1–5pm · free',desc:'Music, crafts, yard games, drinks and snacks, a calm corner, a photo booth, temporary tattoos and more. Rebecca Sunshine Band plays 2–3pm in the Good Shepherd backyard on Tibbets Ave.'},
{a:'489 Prince Avenue',name:'Old Fire Hall No. 2',note:'Merch · 12:30–6pm',kind:'shirt',title:'Historic Athens merch table',when:'12:30–6pm',desc:'Official Porchfest merch, sold in person only, at Historic Athens\' Old Fire Hall No. 2. Bichos Vivos plays here at 6pm.'},
{a:'650 Pulaski St',name:"Mayors' Show",note:'7pm finale',kind:'star',title:"The Mayors' Show",when:'7pm',desc:'The closing show at Stan Mullins Studio. Athens Mayor Kelly Girtz introduces Winterville Mayor and singer-songwriter Dodd Ferrelle. It\'s the only set at 7pm.',merch:'6:30–8pm'},
].map(l=>({...l,p:P.find(p=>p.a===l.a)})).filter(l=>l.p);
const ICONS={
truck:g=>{el('path',{class:'ic',d:'M-8.5,-5.5h11v9h-11zM3.2,-2.2h3.4l2.6,2.8v6.2h-6z'},g);el('path',{class:'icbg',d:'M-6.8,-3.6h7.2v3h-7.2zM4.6,-0.8h1.6l1.4,1.5v.8h-3z'},g);[[-4.6,4.8],[5.2,4.8]].forEach(([cx,cy])=>{el('circle',{class:'ic',cx,cy,r:2.4},g);el('circle',{class:'icbg',cx,cy,r:1},g)})},
kids:g=>{el('ellipse',{class:'ic',cx:0,cy:-2.6,rx:5.6,ry:6.4},g);el('path',{class:'ic',d:'M-1.4,3.4h2.8l-1.4,2z'},g);el('path',{d:'M0,5.4c-1.6,1.6 1.6,2.4 0,4',fill:'none',class:'icstroke'},g);el('ellipse',{class:'icbg',cx:-2,cy:-4.6,rx:1.3,ry:2},g)},
shirt:g=>{el('path',{class:'ic',d:'M-3.4,-7.6L-9,-4.6L-6.8,0L-4.8,-1V8H4.8V-1L6.8,0L9,-4.6L3.4,-7.6C2.6,-5.4 -2.6,-5.4 -3.4,-7.6Z'},g)},
star:g=>{el('path',{class:'ic',d:'M0,-9L2.6,-3.1L9,-2.8L4,1.4L5.6,7.8L0,4.3L-5.6,7.8L-4,1.4L-9,-2.8L-2.6,-3.1Z'},g)},
palette:g=>{el('path',{class:'ic',d:'M0,-8.5C6.2,-8.5 9.2,-4.5 9.2,-0.4C9.2,3 6.6,3.3 4.6,3.1C2.6,2.9 2,4.6 3,6.1C4.1,7.9 2.4,9.2 0,9.2C-6,9.2 -9.2,5 -9.2,0C-9.2,-5.1 -5.2,-8.5 0,-8.5Z'},g);[[-4.4,-2.6],[0.4,-5.2],[5,-2.4],[-4.8,2.8]].forEach(([cx,cy])=>el('circle',{class:'icbg',cx,cy,r:1.7},g))},
paw:g=>{el('ellipse',{class:'ic',cx:0,cy:3.4,rx:4.8,ry:4},g);[[-5.6,-1.6,2.1],[-2.2,-5.8,2.3],[2.2,-5.8,2.3],[5.6,-1.6,2.1]].forEach(([cx,cy,r])=>el('circle',{class:'ic',cx,cy,r},g))},
};
function closeMarks(){document.querySelectorAll('.mark.open').forEach(m=>m.classList.remove('open'))}
function makeMark({x,y,dy=0,kind,cls='',name,note,title,onOpen}){
const g=el('g',{class:'mark '+cls,transform:`translate(${x},${y})`},document.getElementById('marks'));const inner=el('g',{},g);
if(dy)el('line',{class:'stem',x1:0,y1:-5,x2:0,y2:dy+12},inner);
const wrap=el('g',{transform:`translate(0,${dy})`},inner);
const call=el('g',{class:'call',role:'button'},wrap);const w=Math.max(name.length*7.4,note.length*8)+40;
el('line',{x1:0,y1:-14,x2:0,y2:-24},call);el('rect',{x:-w/2,y:-64,width:w,height:40,rx:8},call);
const t1=el('text',{class:'mn',x:-9,y:-47,'text-anchor':'middle'},call);t1.textContent=name;const t2=el('text',{class:'mt',x:-9,y:-32,'text-anchor':'middle'},call);t2.textContent=note;
const ch=el('text',{class:'mn chev',x:w/2-15,y:-39,'text-anchor':'middle'},call);ch.textContent='›';
const badge=el('g',{class:'badge'},wrap);el('circle',{class:'bg',r:13},badge);ICONS[kind](el('g',{},badge));
el('title',{},badge).textContent=title||`${name}: ${note}`;
badge.addEventListener('click',e=>{if(moved)return;e.stopPropagation();const open=!g.classList.contains('open');closeMarks();if(open){g.classList.add('open');g.parentNode.appendChild(g)}});
call.addEventListener('click',e=>{if(moved)return;e.stopPropagation();closeMarks();onOpen()});
return inner}
const markEls=LANDMARKS.map(l=>{l.p.landmark=l;return makeMark({x:l.p.x,y:l.p.y,dy:-24,kind:l.kind,name:l.name,note:l.note,title:`${l.name}: ${l.note} · ${l.p.a}`,onOpen:()=>openPorch(l.p.id)})});
const PARADE=(()=>{const ROUTE=[[887,-361],[420,-412],[386,-165],[445,-156],[618,-88],[685,-76],[741,-376],[887,-361]];const segs=[{pts:ROUTE}];const g=document.getElementById('paradeG');segs.forEach(w=>{const p=el('path',{d:pathD(w.pts),class:'parade'},g);el('title',{},p).textContent='Boo-le-Bark pet parade route, 4:30pm';p.addEventListener('click',e=>{if(moved)return;e.stopPropagation();openEvent()})});
const best=segs.reduce((a,b)=>len(b.pts)>len(a.pts)?b:a);let half=len(best.pts)/2,lx=best.pts[0][0],ly=best.pts[0][1];for(let i=1;i<best.pts.length;i++){const [x0,y0]=best.pts[i-1],[x1,y1]=best.pts[i];const L=Math.hypot(x1-x0,y1-y0);if(half<=L){lx=x0+(x1-x0)*half/L;ly=y0+(y1-y0)*half/L;break}half-=L}
const all=segs.flatMap(w=>w.pts).filter(([x,y])=>x>B.x0-200&&x<B.x1+200);const bounds={x0:Math.min(...all.map(p=>p[0]))-60,x1:Math.max(...all.map(p=>p[0]))+60,y0:Math.min(...all.map(p=>p[1]))-160,y1:Math.max(...all.map(p=>p[1]))+80};
const name='Boo-le-Bark pet parade';lx=ROUTE[0][0];ly=ROUTE[0][1];markEls.push(makeMark({x:lx,y:ly,kind:'paw',cls:'pm',name,note:'4:30pm · starts here',onOpen:()=>openEvent()}));
// direction arrows at the middle of each leg
for(let i=0;i<ROUTE.length-1;i++){const [x0,y0]=ROUTE[i],[x1,y1]=ROUTE[i+1];if(Math.hypot(x1-x0,y1-y0)<120)continue;const a=Math.atan2(y1-y0,x1-x0)*180/Math.PI;const ag=el('g',{transform:`translate(${(x0+x1)/2},${(y0+y1)/2}) rotate(${a})`},g);const inner=el('g',{},ag);el('path',{class:'parrow',d:'M-7,-9 L6,0 L-7,9'},inner);markEls.push(inner)}
return {name,lx,ly,bounds,route:ROUTE,paths:[...g.children]}})();
const byAddr=a=>P.find(p=>p.a===a);
const TROLLEY=[
{name:'Historic Athens',where:'Hill St at Prince Ave',p:byAddr('489 Prince Avenue')},
{name:'Hancock & Rocksprings',where:'W Hancock Ave at N Rocksprings St',x:56,y:687},
{name:'Holman & Price',where:'Holman Ave at Price Ave',x:-1115,y:675},
{name:'Holman & Edwards',where:'Holman Ave at Edwards Dr',x:-1268,y:209},
{name:'Athentic Brewing',where:'108 Park Ave',p:byAddr('108 Park Ave')},
{name:'Boulevard & Park Ave',where:'Boulevard at Park Ave',x:-313,y:-499},
{name:'Boulevard & Chase',where:'Boulevard at N Chase St',x:200,y:-439},
{name:'175 Tracy St',where:'Mill district courtyard',p:byAddr('175 Tracy St')},
{name:'425 Barber St',where:'Normaltown Brewing',p:byAddr('425 Barber Street')},
].map((s,i)=>({...s,n:i+1,x:s.p?s.p.x:s.x,y:s.p?s.p.y:s.y}));
document.getElementById('trolleyRoute').setAttribute('d',pathD([...TROLLEY,TROLLEY[0]].map(s=>[s.x,s.y])));
const stopEls=TROLLEY.map((s,i)=>{const g=el('g',{class:'tstop',transform:`translate(${s.x},${s.y})`},document.getElementById('tstops'));const inner=el('g',{},g);
el('rect',{x:-11,y:-11,width:22,height:22,rx:5},inner);const t=el('text',{y:4.5},inner);t.textContent=s.n;el('title',{},g).textContent=`Trolley stop ${s.n}: ${s.name}`;
g.addEventListener('click',e=>{if(moved)return;e.stopPropagation();openStop(i)});return {g,inner}});
const GEO={lat0:33.962,lng0:-83.395};GEO.kx=111320*Math.cos(GEO.lat0*Math.PI/180);GEO.ky=110574;
const meDist=p=>S.me?Math.hypot(p.x-S.me.x,p.y-S.me.y):0;
const walkMin=p=>{const m=meDist(p);return m<30?'here':`${Math.max(1,Math.round(m*1.3/80))} min`};
let geoWatch=null,geoFirst=true;
function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.hidden=false;clearTimeout(toast.t);toast.t=setTimeout(()=>t.hidden=true,4200)}
function drawMe(){const g=document.getElementById('me');g.innerHTML='';if(!S.me)return;el('circle',{class:'acc',cx:S.me.x,cy:S.me.y,r:Math.min(S.me.acc,400)},g);el('circle',{class:'dot',cx:S.me.x,cy:S.me.y,r:0},g);renderPinsScale()}
function stopLocate(){if(geoWatch!=null)navigator.geolocation.clearWatch(geoWatch);geoWatch=null;S.me=null;drawMe();document.getElementById('zloc').setAttribute('aria-pressed','false');render()}
function startLocate(){if(!navigator.geolocation){toast('This browser can\'t share your location.');return}
const btn=document.getElementById('zloc');btn.setAttribute('aria-pressed','true');geoFirst=true;
try{geoWatch=navigator.geolocation.watchPosition(pos=>{const {latitude:lat,longitude:lng,accuracy}=pos.coords;S.me={x:(lng-GEO.lng0)*GEO.kx,y:(GEO.lat0-lat)*GEO.ky,acc:accuracy||30};drawMe();
const inside=S.me.x>B.x0-1500&&S.me.x<B.x1+1500&&S.me.y>B.y0-1500&&S.me.y<B.y1+1500;
if(geoFirst){geoFirst=false;if(inside){panTo(S.me,0.45);toast('Lists now show the nearest porches first.')}else toast('You\'re outside the Porchfest area, so distances will be long.')}
render()},err=>{btn.setAttribute('aria-pressed','false');geoWatch=null;S.me=null;drawMe();render();toast(err.code===1?'Location is turned off for this page. Allow it in your browser settings to see nearby porches.':'Couldn\'t find your location. Try again outside or in a minute.')},{enableHighAccuracy:true,maximumAge:15000,timeout:20000})}catch(e){btn.setAttribute('aria-pressed','false');toast('Location isn\'t available here.')}}
function nearestStop(p){let best=null;TROLLEY.forEach(s=>{const d=Math.hypot(s.x-p.x,s.y-p.y);if(!best||d<best.d)best={s,d}});return best}
const gPins=document.getElementById('pins');const pinEls=[];
P.forEach(p=>{const g=el('g',{class:'pin',transform:`translate(${p.x},${p.y})`},gPins);g.dataset.id=p.id;
const halo=el('circle',{class:'halo',r:0},g),ring=el('circle',{class:'ring',r:0},g),sr=el('circle',{class:'starring',r:0},g),dot=el('circle',{class:'dot',r:0},g);
const ttl=el('title',{},g);
g.addEventListener('click',e=>{if(moved)return;e.stopPropagation();openPorch(p.id)});
pinEls[p.id]={g,halo,dot,ring,sr,ttl,state:null}});
function renderPins(){const vis=visibleActs();const byP=new Map();vis.forEach(a=>{if(!byP.has(a.p))byP.set(a.p,[]);byP.get(a.p).push(a)});
P.forEach(p=>{const pe=pinEls[p.id];const acts=byP.get(p.id);let st;
if(acts){const a=acts[0];st={on:true,color:gColor(a.g),star:acts.some(x=>S.stars.has(x.id))||(S.view==='lineup'&&p.acts.some(x=>S.stars.has(x.id)&&(S.hour===0||x.t===S.hour)))};pe.ttl.textContent=acts.map(x=>`${x.t}pm ${x.n}`).join('\n')+'\n'+p.a}
else{const anyMatch=S.view==='lineup'&&p.acts.some(passes);st={on:false,ghost:!anyMatch};pe.ttl.textContent=p.a+' — '+p.acts.map(x=>`${x.t}pm ${x.n}`).join(', ')}
st.sel=S.porch===p.id||(S.open!=null&&A[S.open].p===p.id);st.hl=S.hl===p.id;pe.state=st;
pe.g.classList.toggle('off',!st.on);pe.g.classList.toggle('sel',st.sel);
pe.dot.style.fill=st.on?st.color:'';pe.g.style.opacity=st.on||st.sel?1:st.ghost?0.25:0.7;
if(st.on||st.sel)gPins.appendChild(pe.g)});
// keep selected on top
if(S.porch!=null)gPins.appendChild(pinEls[S.porch].g);
renderRoute();renderPinsScale()}
function renderPinsScale(){const k=V.k;markEls.forEach(m=>m.setAttribute('transform',`scale(${1/k})`));const md=document.querySelector('#me .dot');if(md){md.setAttribute('r',7/k);md.setAttribute('stroke-width',3/k)}stopEls.forEach((s,i)=>{s.inner.setAttribute('transform',`scale(${Math.min(1.15,Math.max(.75,Math.sqrt(k/0.3)))/k})`);s.g.classList.toggle('sel',S.stop===i)});const z=Math.min(1.35,Math.max(.8,Math.sqrt(k/0.25)));
pinEls.forEach(pe=>{const s=pe.state;if(!s)return;const r=(s.on?7:3.6)*z*(s.hl?1.35:1)/k;
pe.dot.setAttribute('r',r);pe.dot.setAttribute('stroke-width',(s.on?2:1)/k);
pe.halo.setAttribute('r',s.on&&S.view==='lineup'?r*2.1:0);
pe.ring.setAttribute('r',s.sel?r+5/k:0);pe.ring.setAttribute('stroke-width',2.5/k);
pe.sr.setAttribute('r',s.star?r+(s.sel?9:3.5)/k:0);pe.sr.setAttribute('stroke-width',2.5/k)});
document.querySelectorAll('.routeNum').forEach(t=>{t.setAttribute('font-size',12/k);t.setAttribute('stroke-width',3/k);t.setAttribute('dy',-13/k)})}
function planActs(){return A.filter(a=>S.stars.has(a.id)).sort((x,y)=>x.t-y.t||x.porch.x-y.porch.x)}
function renderRoute(){const r=document.getElementById('route'),nums=document.getElementById('routeNums');nums.innerHTML='';
const pl=planActs();if(pl.length<1){r.setAttribute('d','');return}
r.setAttribute('d',pl.length>1?pathD(pl.map(a=>[a.porch.x,a.porch.y])):'');r.style.display=S.view==='plan'||S.hour===0?'':'none';
if(S.view==='plan'){const seen={};pl.forEach((a,i)=>{const key=a.p;const t=el('text',{class:'routeNum',x:a.porch.x,y:a.porch.y},nums);t.textContent=(seen[key]?seen[key]+',':'')+a.t;seen[key]=t.textContent;if(seen[key]!==String(a.t)){/*merge*/}})}}
// ---------- panel ----------
const listEl=document.getElementById('list');
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
function walkText(m){const route=m*1.3;const min=Math.max(1,Math.round(route/80));const mi=(route/1609).toFixed(route<1609?2:1);return `${min} min walk · ${mi} mi`}
function starBtn(a){return `<button class="starbtn" data-star="${a.id}" aria-pressed="${S.stars.has(a.id)}" aria-label="${S.stars.has(a.id)?'Remove from':'Add to'} my day: ${esc(a.n)}">${S.stars.has(a.id)?'★':'☆'}</button>`}
function actRow(a,showTime){return `<div class="act${S.hl===a.p?' hl':''}" data-porch="${a.p}" data-act="${a.id}" tabindex="0" aria-expanded="${S.open===a.id}"><span class="sw" style="background:${gColor(a.g)}"></span><div style="min-width:0"><div class="nm">${esc(a.n)}${a.r?' <span class="rtag" title="Recommended in u/one98d\'s r/Athens primer">r/Athens pick</span>':''}</div><div class="meta">${showTime?`<span class="t">${a.t}pm</span> · `:''}${esc(a.porch.a)} · ${esc(a.g)}${S.me?` · <span class="near">${walkMin(a.porch)}</span>`:''}</div>${S.open===a.id&&S.porch==null?detail(a):''}</div>${starBtn(a)}</div>`}
function render(){
document.querySelectorAll('#hours button').forEach(b=>b.setAttribute('aria-pressed',S.view==='lineup'&&+b.dataset.h===S.hour));
document.getElementById('tabLineup').setAttribute('aria-pressed',S.view==='lineup');document.getElementById('tabPlan').setAttribute('aria-pressed',S.view==='plan');
document.getElementById('planCount').textContent=S.stars.size?S.stars.size:'';
document.getElementById('hours').style.opacity=S.view==='plan'?.5:1;
const st=document.getElementById('status');
if(S.porch!=null){renderPorch(P[S.porch])}
else if(S.help){renderHelp()}
else if(S.event){renderEvent()}
else if(S.stop!=null){renderStop(TROLLEY[S.stop])}
else if(S.view==='plan'){renderPlan()}
else{const vis=visibleActs();
const filtered=S.q||S.hood||S.fams.size<FAMILIES.length;
st.innerHTML=`<span><b>${vis.length}</b> ${vis.length===1?'act':'acts'} ${S.hour?`starting at ${S.hour}pm`:'all day'}${S.me?' · nearest first':''}</span>${filtered?'<button class="linkbtn" id="clear">Clear filters</button>':''}`;
setTimeout(()=>{if(S.view==='lineup'&&S.porch==null&&S.stop==null&&!S.event&&(S.hour===3||S.hour===4||S.hour===0)&&!S.q)listEl.insertAdjacentHTML('afterbegin',S.hour===3?eventRow('contest'):S.hour===0?eventRow('contest')+eventRow():eventRow())});
if(!vis.length){listEl.innerHTML=`<div class="empty"><strong>Nobody matches here</strong>Try another hour, or clear the search and genre filters.</div>`}
else if(S.hour===0){const by={};vis.forEach(a=>(by[a.t]||(by[a.t]=[])).push(a));listEl.innerHTML=Object.keys(by).sort((a,b)=>a-b).map(h=>`<section class="group"><h3><span>${h}pm</span><span>${by[h].length}</span></h3>${by[h].sort(S.me?(a,b)=>meDist(a.porch)-meDist(b.porch):(a,b)=>a.porch.h.localeCompare(b.porch.h)||a.n.localeCompare(b.n)).map(a=>actRow(a,false)).join('')}</section>`).join('')}
else if(S.me){listEl.innerHTML=`<section class="group"><h3><span>Nearest to you</span><span>${vis.length}</span></h3>${[...vis].sort((a,b)=>meDist(a.porch)-meDist(b.porch)).map(a=>actRow(a,false)).join('')}</section>`}
else{const by={};vis.forEach(a=>(by[a.porch.h]||(by[a.porch.h]=[])).push(a));listEl.innerHTML=Object.keys(by).sort().map(h=>`<section class="group"><h3><span>${esc(h)}</span><span>${by[h].length}</span></h3>${by[h].sort((a,b)=>a.n.localeCompare(b.n)).map(a=>actRow(a,false)).join('')}</section>`).join('')}
const c=document.getElementById('clear');if(c)c.onclick=()=>{S.q='';S.hood='';S.fams=new Set(FAMILIES.map(f=>f.k));document.getElementById('q').value='';hoodSel.value='';renderChips();update()};
}
updFiltCount&&updFiltCount();renderPins()}
function linkRow(a){const l=[];if(a.m)l.push(`<a href="${esc(a.m)}" target="_blank" rel="noopener">Listen ↗</a>`);if(a.v)l.push(`<a href="${esc(a.v)}" target="_blank" rel="noopener">Tip the band ↗</a>`);else if(a.vt)l.push(`<span>Tips: ${esc(a.vt)}</span>`);return l.length?`<div class="lk">${l.join('')}</div>`:''}
function renderPorch(p){const st=document.getElementById('status');st.innerHTML=`<button class="linkbtn" id="back">← Back to ${S.view==='plan'?'my day':'the lineup'}</button><span>${p.acts.length} ${p.acts.length===1?'set':'sets'} here</span>`;
document.getElementById('back').onclick=closePorch;
const maps=`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(p.a+', Athens, GA')}&travelmode=walking`;
listEl.innerHTML=`<div class="porch"><h2>${esc(p.a)}</h2><div class="sub">${esc(p.h)} · hosted by ${esc(p.hosts.join(' & '))}</div>${S.me?`<div class="sub"><span class="near">${walkMin(p)==='here'?'You\'re here':walkMin(p)+' walk'}</span> from where you are</div>`:''}${p.landmark?`<div class="lmkcard"><b>${esc(p.landmark.title)}</b><span class="lw">${esc(p.landmark.when)}</span><p>${esc(p.landmark.desc)}</p>${p.landmark.merch?`<span class="mer">Porchfest merch for sale here, ${esc(p.landmark.merch)}. In person only.</span>`:''}</div>`:''}<div class="links"><a href="${maps}" target="_blank" rel="noopener">Walking directions ↗</a></div>${(()=>{const ns=nearestStop(p);return ns.d<700?`<div class="tline"><span class="tnum">${ns.s.n}</span><span>Nearest trolley stop: <button class="tbtn" data-stop="${ns.s.n-1}">${esc(ns.s.name)}</button>, ${ns.d<25?'right here':walkText(ns.d).split(' ·')[0]}</span></div>`:''})()}
${p.acts.map(a=>`<div class="slot${S.view==='lineup'&&a.t===S.hour?' cur':''}"><div class="t">${a.t}pm</div><div style="min-width:0"><div class="nm">${esc(a.n)}</div><div class="g"><span class="sw" style="background:${gColor(a.g)}"></span>${esc(a.g)}${a.host!==p.hosts[0]&&p.hosts.length>1?' · '+esc(a.host):''}</div>${a.d?`<div class="d">${esc(a.d.length>320?a.d.slice(0,300)+'…':a.d)}</div>`:''}${primerBlock(a)}${linkRow(a)}${S.open===a.id&&S.porch==null?detail(a):''}</div>${starBtn(a)}</div>`).join('')}</div>`}
function renderPlan(){const st=document.getElementById('status');const pl=planActs();
let total=0;for(let i=1;i<pl.length;i++)total+=dist(pl[i-1].porch,pl[i].porch);
st.innerHTML=pl.length?`<span><b>${pl.length}</b> picks · about ${walkText(total).replace(' walk','').replace('·','of walking ·')}</span><button class="linkbtn" id="clearPlan">Clear</button>`:'<span>Star sets to build your route</span>';
if(!pl.length){listEl.innerHTML=`<div class="empty"><strong>Your day is open</strong>Tap ☆ next to any band in the lineup or on a porch. They'll show up here in time order with walking times between porches, and the route draws on the map.</div>`;return}
let html='';const byH={};pl.forEach(a=>(byH[a.t]=(byH[a.t]||0)+1));
let evDone=false;
pl.forEach((a,i)=>{if(!evDone&&a.t>=5){html+=eventRow();evDone=true}if(i>0){const d=dist(pl[i-1].porch,a.porch);if(d>1){html+=`<div class="walk">${walkText(d)}</div>`;if(d*1.3>900){const s1=nearestStop(pl[i-1].porch),s2=nearestStop(a.porch);if(s1.s!==s2.s&&s1.d<500&&s2.d<500)html+=`<div class="thint">or take the free trolley: stop ${s1.s.n} (${esc(s1.s.name)}) → stop ${s2.s.n} (${esc(s2.s.name)})</div>`}}}
html+=`<div class="act" data-porch="${a.p}" data-act="${a.id}" tabindex="0" aria-expanded="${S.open===a.id}"><span class="sw" style="background:${gColor(a.g)}"></span><div style="min-width:0"><div class="nm">${esc(a.n)}</div><div class="meta"><span class="t">${a.t}pm</span> · ${esc(a.porch.a)} · ${esc(a.porch.h)}</div>${byH[a.t]>1?`<div class="warn">${byH[a.t]} picks start at ${a.t}pm</div>`:''}${S.open===a.id&&S.porch==null?detail(a):''}</div>${starBtn(a)}</div>`});
if(!evDone)html+=eventRow();
html+=`<div class="plancard">Sets run about 45 minutes, so a 10-minute walk still gets you to the next porch near the top of the hour. Your picks are saved in this browser.</div>`;
listEl.innerHTML=html;
const c=document.getElementById('clearPlan');if(c){c.onclick=()=>{if(c.dataset.arm){S.stars.clear();saveStars();update()}else{c.dataset.arm=1;c.textContent='Tap again to clear all'}}}}
function renderStop(s){const st=document.getElementById('status');st.innerHTML=`<button class="linkbtn" id="back">← Back to ${S.view==='plan'?'my day':'the lineup'}</button><span>Stop ${s.n} of ${TROLLEY.length}</span>`;
document.getElementById('back').onclick=closePorch;
const prev=TROLLEY[(s.n-2+TROLLEY.length)%TROLLEY.length],next=TROLLEY[s.n%TROLLEY.length];
const near=A.filter(a=>(S.view==='plan'||S.hour===0||a.t===S.hour)&&passes(a)).map(a=>({a,d:Math.hypot(a.porch.x-s.x,a.porch.y-s.y)})).filter(o=>o.d<=370).sort((x,y)=>x.a.t-y.a.t||x.d-y.d);
const when=S.view==='lineup'&&S.hour?`at ${S.hour}pm`:'all day';
listEl.innerHTML=`<div class="porch"><div class="tline" style="margin:0 0 6px"><span class="tnum">${s.n}</span><span>Free hop-on, hop-off trolley</span></div><h2>${esc(s.name)}</h2><div class="sub">${esc(s.where)} · Athens Trolley Tours loops all nine stops, 1–7pm</div>
<div class="stopnav"><button class="tbtn" data-stop="${prev.n-1}">← ${prev.n} · ${esc(prev.name)}</button><button class="tbtn" data-stop="${next.n-1}">${next.n} · ${esc(next.name)} →</button></div>
<section class="group"><h3><span>Within a 6-minute walk, ${when}</span><span>${near.length}</span></h3>${near.length?near.map(({a,d})=>`<div class="act" data-porch="${a.p}" data-act="${a.id}" tabindex="0" aria-expanded="${S.open===a.id}"><span class="sw" style="background:${gColor(a.g)}"></span><div style="min-width:0"><div class="nm">${esc(a.n)}</div><div class="meta"><span class="t">${a.t}pm</span> · ${esc(a.porch.a)} · ${d<25?'at the stop':walkText(d).split(' ·')[0]}</div>${S.open===a.id&&S.porch==null?detail(a):''}</div>${starBtn(a)}</div>`).join(''):'<div class="empty">No matching sets this close. Try All day or another hour.</div>'}</section></div>`}
function renderHelp(){const st=document.getElementById('status');st.innerHTML=`<button class="linkbtn" id="back">← Back to ${S.view==='plan'?'my day':'the lineup'}</button><span>How it works</span>`;document.getElementById('back').onclick=closePorch;
const sq=(bg,fg,t)=>`<span class="ico" style="background:${bg};color:${fg};border-radius:5px;font-family:var(--f-mono);font-size:11px;font-weight:600">${t}</span>`;
listEl.innerHTML=`<div class="porch help">
<h2>About this map</h2>
<p class="lede">An interactive map of Historic Athens Porchfest, Sunday, October 18, from 1 to 7pm. It puts all 237 sets on 193 porches on one map, so you can see who's playing near you each hour and plan a walking route through the day.</p>
<p>This is an unofficial fan-made planner, not a Historic Athens site. For the official map and news, visit <a href="https://historicathensporchfest.com" target="_blank" rel="noopener">historicathensporchfest.com ↗</a>.</p>
<h3>Find music by the hour</h3>
<ul>
<li>Tap an hour (1 to 7pm) to light up the porches with a set starting then. <b>All</b> shows the whole day. On the day itself, the page opens on the hour that's about to start.</li>
<li>Dot colors show the genre. Faded dots are porches with nothing at that hour.</li>
<li>Search finds bands, hosts and streets. <b>Filters</b> narrows by neighborhood and genre.</li>
<li>Bands tagged <span class="rtag">r/Athens pick</span> were recommended in u/one98d's primer on Reddit. Open the band to read the note.</li>
</ul>
<h3>Open a band or a porch</h3>
<ul>
<li>Tap a band in the list to see its description, a Listen link, a way to tip, and walking directions.</li>
<li>Tap a dot on the map, or <b>All sets at this porch</b>, to see everything playing at that address through the day.</li>
<li>Drag to move the map. Pinch or use + and − to zoom, and ⤢ to see every porch again.</li>
</ul>
<h3>Plan your day</h3>
<ul>
<li>Tap ☆ next to any band to add it to <b>My day</b>.</li>
<li>My day lists your picks in time order with walking times between porches, warns when two picks start at the same hour, and draws your route on the map.</li>
<li>Your picks are saved in this browser on this device. Plan on the phone you'll carry. Nothing is sent anywhere, and no one else can see your list.</li>
</ul>
<h3>Where am I</h3>
<p>Tap ${'◎'} on the map to show your location as a blue dot. Lists then sort by distance and show how many minutes' walk each porch is. Your location stays on your phone. Tap the button again to turn it off.</p>
<h3>Also on the map</h3>
<ul>
<li>${sq('var(--trolley)','var(--trolley-fg)','1')} A free hop-on, hop-off trolley from Athens Trolley Tours loops nine stops from 1 to 7pm. Tap a stop to see what's playing within a few minutes' walk. The dotted line shows the stop order, not the exact streets.</li>
<li><span class="ico" style="background:var(--mark)"></span> Round icons mark the extras: the Food Truck Park at Athentic Brewing and the Indie South Vendor Market at Normaltown Brewing (both 12 to 7pm), the free Good Shepherd Kids Zone (1 to 5pm), the merch table at Old Fire Hall No. 2, and the Mayors' Show finale at 7pm. Tap an icon for its name, then tap the name for details and that porch's lineup.</li>
<li><span class="ico" style="background:var(--parade)"></span> Athens Pets\' Boo-le-Bark has a costume contest at 3pm at Boulevard Woods Park (298 Barber St), then a costumed pet parade at 4:30pm that loops along Boulevard, Lyndon, Dubose and Grady. Tap the paw for the full route.</li>
</ul>
<h3>Merch</h3>
<p>Official Porchfest merch is sold in person only, on Sunday at:</p>
<ul>
<li>Old Fire Hall No. 2, 489 Prince Ave, 12:30 to 6pm</li>
<li>Athentic Brewing Food Truck Park, 108 Park Ave, 12:30 to 6pm</li>
<li>Indie South Vendor Market at Normaltown Brewing, 425 Barber St, 12:30 to 6:30pm</li>
<li>The Mayors' Show at Stan Mullins Studio, 650 Pulaski St, 6:30 to 8pm</li>
</ul>
<h3>Where the information comes from</h3>
<ul>
<li>The lineup comes from the official Historic Athens Porchfest 2026 schedule as of October 17 at 4:30pm, and the special events and merch times from historicathensporchfest.com. Last-minute changes may not be reflected, so double-check anything you can't miss.</li>
<li>Band notes tagged r/Athens pick are by <a href="${PRIMER.userUrl}" target="_blank" rel="noopener">u/one98d</a>, from the <a href="${PRIMER.url}" target="_blank" rel="noopener">small/large primer to the Porchfest schedule ↗</a>.</li>
<li>Porch locations come from the U.S. Census geocoder, and walking times are rough estimates.</li>
<li>Streets are drawn from <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> data, © OpenStreetMap contributors.</li>
</ul>
<p style="margin-top:18px;color:var(--muted)">Made in Athens by <a href="https://blog.kestrelsnest.social" target="_blank" rel="noopener">Eric Wagoner</a>. Have a great Porchfest, and tip the bands.</p>
</div>`}
function openHelp(){S.porch=null;S.stop=null;S.event=false;S.open=null;S.help=true;render();listEl.scrollTop=0;pushDetail();reveal()}
function eventRow(kind){const c=kind==='contest';return `<button class="evrow" data-event="1"><span class="evdot"></span><span><b>${c?'Boo-le-Bark costume contest':PARADE.name}</b><span class="meta"><span class="t">${c?'3pm':'4:30pm'}</span> · ${c?'Boulevard Woods Park, 298 Barber St':'loop from Boulevard &amp; Barber St'}</span></span><span aria-hidden="true">→</span></button>`}
function renderEvent(){const st=document.getElementById('status');st.innerHTML=`<button class="linkbtn" id="back">← Back to ${S.view==='plan'?'my day':'the lineup'}</button><span>Special event</span>`;document.getElementById('back').onclick=closePorch;
const segD=(p,a,b)=>{const dx=b[0]-a[0],dy=b[1]-a[1],L=dx*dx+dy*dy;let t=L?((p.x-a[0])*dx+(p.y-a[1])*dy)/L:0;t=Math.max(0,Math.min(1,t));return Math.hypot(p.x-a[0]-t*dx,p.y-a[1]-t*dy)};
const R=PARADE.route,routeD=p=>Math.min(...R.slice(1).map((b,i)=>segD(p,R[i],b)));
const on=A.filter(a=>a.t===4&&routeD(a.porch)<=45).sort((x,y)=>x.porch.a.localeCompare(y.porch.a,undefined,{numeric:true}));
const ns=nearestStop({x:PARADE.lx,y:PARADE.ly});
listEl.innerHTML=`<div class="porch"><h2>${PARADE.name}</h2><div class="sub">4:30pm · starts and ends at Boulevard &amp; Barber Street (298 Barber St)</div><div class="lmkcard"><b>Costume contest at 3pm</b><span class="lw">Boulevard Woods Park · 298 Barber St</span><p>This year\'s theme is “Boogie on the Boulevard.” The contest and parade benefit Athens Pets.</p></div><p class="d" style="margin:10px 0 0;font-size:15px;line-height:1.5">The parade starts at 4:30pm. The route heads west on Boulevard, south on Lyndon Avenue, east on Dubose Avenue, north on Grady Avenue, then east on Boulevard back to the start. It\'s shown in pink on the map, with arrows for direction. Boo-le-Bark and Historic Athens schedule the two events for the same day, so you can catch a porch set and the parade together.</p>
<div class="tline"><span class="tnum">7</span><span>Trolley stop <button class="tbtn" data-stop="6">7 · Boulevard & Chase</button> is about a 4-minute walk west of the route</span></div>
<section class="group"><h3><span>Playing along the route from 4pm</span><span>${on.length}</span></h3>${on.map(a=>actRow(a,true)).join('')||'<div class="empty">No 4pm sets along the route.</div>'}</section></div>`}
function openEvent(){S.porch=null;S.stop=null;S.event=true;S.hl=null;render();const {w,h}=size();const b=PARADE.bounds;fitTo(b,40);listEl.scrollTop=0;pushDetail();reveal()}
function openStop(i){S.porch=null;S.event=false;S.open=null;S.help=false;S.stop=i;S.hl=null;render();panTo(TROLLEY[i],0.5);listEl.scrollTop=0;pushDetail();reveal()}
const PRIMER={user:'u/one98d',userUrl:'https://www.reddit.com/user/one98d/',url:'https://www.reddit.com/r/Athens/comments/1x8nx8n/smalllarge_primer_to_the_athens_porchfest_music/'};
function primerBlock(a){return a.r?`<div class="rq"><div class="rqh">Picked by <a href="${PRIMER.userUrl}" target="_blank" rel="noopener">${PRIMER.user}</a> in the <a href="${PRIMER.url}" target="_blank" rel="noopener">r/Athens primer ↗</a></div><div class="rqt">${esc(a.r)}</div></div>`:''}
function detail(a){const p=a.porch;const maps=`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(p.a+', Athens, GA')}&travelmode=walking`;const others=p.acts.filter(x=>x!==a);
return `<div class="det">${a.d?`<div class="d">${esc(a.d)}</div>`:''}${primerBlock(a)}<div class="dm">${esc(a.g)} · ${esc(p.h)} · hosted by ${esc(a.host)}</div>${linkRow(a)}<div class="lk"><button class="tbtn" data-openporch="${p.id}">${others.length?`All ${p.acts.length} sets at this porch`:'Porch details'} →</button><a href="${maps}" target="_blank" rel="noopener">Walking directions ↗</a></div></div>`}
function toggleAct(id){S.open=S.open===id?null:id;const sc=listEl.scrollTop,wy=window.scrollY;if(S.open!=null)S.hl=null;render();listEl.scrollTop=sc;window.scrollTo(0,wy);if(S.open!=null)panTo(A[id].porch)}
function update(){render()}
function openPorch(id){S.stop=null;S.event=false;S.open=null;S.help=false;S.porch=id;S.hl=null;render();panTo(P[id],0.5);listEl.scrollTop=0;pushDetail();reveal()}
function doClose(){S.porch=null;S.stop=null;S.event=false;S.open=null;S.help=false;render()}
let histOpen=false;
function pushDetail(){if(histOpen)return;try{history.pushState({pfDetail:1},'');histOpen=true}catch(e){}}
function closePorch(){if(histOpen){histOpen=false;try{history.back();return}catch(e){}}doClose()}
window.addEventListener('popstate',()=>{histOpen=false;if(S.porch!=null||S.stop!=null||S.event||S.help)doClose()});
const isPhone=()=>window.matchMedia('(max-width:860px)').matches;
function reveal(onlyIfPast){if(!isPhone())return;const stuck=document.querySelector('.mapwrap').offsetHeight+document.getElementById('bar').offsetHeight;const st=document.getElementById('status');const top=st.getBoundingClientRect().top+window.scrollY-stuck-6;if(onlyIfPast&&window.scrollY<=top)return;try{window.scrollTo({top:Math.max(0,top),behavior:'smooth'})}catch(e){window.scrollTo(0,Math.max(0,top))}}
listEl.addEventListener('click',e=>{const tb=e.target.closest('[data-stop]');if(tb){openStop(+tb.dataset.stop);return}if(e.target.closest('[data-event]')){openEvent();return}const sb=e.target.closest('[data-star]');if(sb){e.stopPropagation();const id=+sb.dataset.star;S.stars.has(id)?S.stars.delete(id):S.stars.add(id);saveStars();render();return}
if(e.target.closest('a'))return;const op=e.target.closest('[data-openporch]');if(op){openPorch(+op.dataset.openporch);return}if(e.target.closest('.det'))return;const ra=e.target.closest('[data-act]');if(ra){toggleAct(+ra.dataset.act);return}const r=e.target.closest('[data-porch]');if(r)openPorch(+r.dataset.porch)});
listEl.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.target.closest('button,a')){const r=e.target.closest('[data-act],[data-porch]');if(r)r.click()}});
listEl.addEventListener('pointerover',e=>{const r=e.target.closest('[data-porch]');const v=r?+r.dataset.porch:null;if(v!==S.hl){S.hl=v;renderPins()}});
listEl.addEventListener('pointerleave',()=>{S.hl=null;renderPins()});
svg.addEventListener('click',e=>{if(!moved&&!e.target.closest('.mark'))closeMarks()});
svg.addEventListener('click',e=>{if(!moved&&!e.target.closest('.pin,.tstop,.mark')&&(S.porch!=null||S.stop!=null||S.event||S.help))closePorch()});
// ---------- controls ----------
const hoursEl=document.getElementById('hours');
[...HOURS,0].forEach(h=>{const b=document.createElement('button');b.dataset.h=h;const n=A.filter(a=>h===0||a.t===h).length;b.innerHTML=h?`${h}<small>${n} sets</small>`:`All<small>day</small>`;if(nowInfo.hour===h)b.classList.add('now'),b.title='Happening now';b.onclick=()=>{S.hour=h;S.view='lineup';S.porch=null;S.stop=null;S.event=false;S.open=null;S.help=false;render();reveal(true)};hoursEl.appendChild(b)});
const genresEl=document.getElementById('genres');
function renderChips(){genresEl.innerHTML=FAMILIES.map(f=>`<button class="chip" data-f="${f.k}" aria-pressed="${S.fams.has(f.k)}"><span class="sw" style="background:${famColor(f)}${f.k==='loud'?';box-shadow:inset 0 0 0 2px var(--panel),0 0 0 1.5px '+famColor(f):''}"></span>${f.label}</button>`).join('')}
genresEl.addEventListener('click',e=>{const b=e.target.closest('[data-f]');if(!b)return;const k=b.dataset.f;
if(S.fams.size===FAMILIES.length){S.fams=new Set([k])}else if(S.fams.has(k)){S.fams.delete(k);if(!S.fams.size)S.fams=new Set(FAMILIES.map(f=>f.k))}else S.fams.add(k);
renderChips();S.porch=null;S.stop=null;S.event=false;S.open=null;S.help=false;S.view='lineup';render()});
renderChips();
document.getElementById('q').addEventListener('input',e=>{S.q=e.target.value.trim();S.porch=null;S.stop=null;S.event=false;S.open=null;S.help=false;S.view='lineup';if(S.q&&S.hour)S.hour=0;render()});
hoodSel.addEventListener('change',e=>{S.hood=e.target.value;S.porch=null;S.stop=null;S.event=false;S.open=null;S.help=false;S.view='lineup';render();const ps=P.filter(p=>!S.hood||p.h===S.hood);fitTo({x0:Math.min(...ps.map(p=>p.x))-80,x1:Math.max(...ps.map(p=>p.x))+80,y0:Math.min(...ps.map(p=>p.y))-140,y1:Math.max(...ps.map(p=>p.y))+80})});
document.getElementById('tabLineup').onclick=()=>{S.view='lineup';S.porch=null;S.stop=null;S.event=false;S.open=null;S.help=false;render()};
document.getElementById('tabPlan').onclick=()=>{S.view='plan';S.porch=null;S.stop=null;S.event=false;S.open=null;S.help=false;render();const pl=planActs();if(pl.length>1){const xs=pl.map(a=>a.porch.x),ys=pl.map(a=>a.porch.y);fitTo({x0:Math.min(...xs)-150,x1:Math.max(...xs)+150,y0:Math.min(...ys)-180,y1:Math.max(...ys)+150})}};
document.getElementById('legend').innerHTML=[['rock','Rock, metal, punk'],['indie','Indie & pop'],['folk','Folk & songwriter'],['jazz','Jazz & classical'],['elec','Electronic & hip-hop'],['other','Everything else']].map(([k,l])=>`<span><i class="sw" style="background:var(--g-${k})"></i>${l}</span>`).join('')+`<span style="grid-column:1/-1"><i class="sw" style="background:var(--mark);border-radius:3px"></i>Food trucks &amp; artist market</span><span style="grid-column:1/-1"><i class="sw" style="background:var(--trolley);border-radius:3px"></i>Free trolley stop (1–7pm loop)</span><span style="grid-column:1/-1"><i class="sw" style="background:var(--parade);border-radius:3px"></i>Boo-le-Bark pet parade route, 4:30pm</span>`;
0&&[].map(([k,l])=>`<span><i class="sw" style="background:var(--g-${k})"></i>${l}</span>`).join('');
let lastSz=null;new ResizeObserver(()=>{const sz=size();if(!window.__fitted){if(sz.w&&sz.h){fitTo(B);window.__fitted=true}}else if(lastSz){V.tx+=(sz.w-lastSz.w)/2;V.ty+=(sz.h-lastSz.h)/2;apply()}lastSz=sz}).observe(svg);
document.getElementById('helpBtn').onclick=e=>{e.stopPropagation();openHelp()};
if(location.hash==='#help')setTimeout(openHelp,0);
document.getElementById('zloc').onclick=()=>{geoWatch!=null||S.me?stopLocate():startLocate()};
const appEl=document.querySelector('.app'),msBtn=document.getElementById('mapSize');
function setMapSmall(on){appEl.classList.toggle('mapsmall',on);msBtn.textContent=on?'Bigger map':'Smaller map';msBtn.setAttribute('aria-label',on?'Make the map bigger':'Make the map smaller');store.set('pf26-mapsmall',on)}
msBtn.onclick=()=>setMapSmall(!appEl.classList.contains('mapsmall'));setMapSmall(!!store.get('pf26-mapsmall',false));
const filtBtn=document.getElementById('filtBtn'),filtEl=document.getElementById('filters');
filtBtn.onclick=()=>{const o=!filtEl.classList.contains('open');filtEl.classList.toggle('open',o);filtBtn.setAttribute('aria-expanded',o)};
function updFiltCount(){const n=(S.hood?1:0)+(S.fams.size<FAMILIES.length?1:0);document.getElementById('filtCount').textContent=n||''}
document.getElementById('q').addEventListener('keydown',e=>{if(e.key==='Enter')e.target.blur()});
render();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB