Download app.js from lysandre/japan-half-marathon-map: direct link, hf CLI and curl.
- Browser
- Download file 12.6 kB
-
https://huggingface.co/spaces/lysandre/japan-half-marathon-map/resolve/main/app.js
- Command line
-
hf download hf://spaces/lysandre/japan-half-marathon-map/app.js
-
curl -L -o app.js https://huggingface.co/spaces/lysandre/japan-half-marathon-map/resolve/main/app.js
12.6 kB
| ; | |
| const REGIONS = [ | |
| {id:"hokkaido", label:"北海道 Hokkaido"}, | |
| {id:"tohoku", label:"東北 Tohoku"}, | |
| {id:"kanto", label:"関東 Kanto"}, | |
| {id:"chubu", label:"中部 Chubu"}, | |
| {id:"kansai", label:"関西 Kansai"}, | |
| {id:"chugoku", label:"中国 Chugoku"}, | |
| {id:"kyushu", label:"九州 Kyushu"}, | |
| ]; | |
| // Order matters: it drives the stats grid, the filter chips and the legend. | |
| const STATUS = { | |
| open: {label:"Open", color:"#22c55e", short:"Open"}, | |
| closing: {label:"Closing soon", color:"#f59e0b", short:"Closing soon"}, | |
| full: {label:"Full 満員", color:"#9ca3af", short:"Full (満員)"}, | |
| closed: {label:"Closed (deadline)", color:"#ef4444", short:"Deadline passed"}, | |
| upcoming: {label:"Upcoming", color:"#a78bfa", short:"Upcoming"}, | |
| }; | |
| const STATUS_KEYS = Object.keys(STATUS); | |
| // Statuses you can actually still sign up for. | |
| const ENTERABLE = ["open","closing"]; | |
| const CLOSING_DAYS = 14; | |
| // Difficulty derived from the profile description. | |
| const DIFFICULTY = [ | |
| {id:"fast", label:"⚡ Flat & fast"}, | |
| {id:"rolling",label:"起伏 Rolling"}, | |
| {id:"tough", label:"⛰ Hilly / tough finish"}, | |
| ]; | |
| function difficultyOf(r){ | |
| const p=(r.profile||"").toLowerCase(); | |
| if(/hilly|brutal| climb|steep|pass/.test(p)) return "tough"; | |
| if(/rolling/.test(p)) return "rolling"; | |
| return "fast"; | |
| } | |
| // ---- date helpers (JST-ish, dates only) ---- | |
| function d(s){ return new Date(s+"T00:00:00+09:00"); } // start-of-day JST | |
| function dayEnd(s){ return new Date(s+"T23:59:59+09:00"); } // end-of-day JST | |
| function today(){ return new Date(); } | |
| function fmtDate(s){ const dt=d(s); return dt.toLocaleDateString("en-GB",{year:"numeric",month:"short",day:"2-digit"}); } | |
| function fmtJP(s){ const dt=d(s); return dt.toLocaleDateString("ja-JP",{month:"long",day:"numeric",weekday:"short"}); } | |
| function daysBetween(a,b){ return Math.round((d(b)-d(a))/86400000); } | |
| function statusOf(r){ | |
| if(r.soldOut) return "full"; // capacity reached — distinct from "the deadline passed" | |
| const now = today(); | |
| const st = d(r.entryStart), en = dayEnd(r.entryEnd); | |
| if(now < st) return "upcoming"; | |
| if(now > en) return "closed"; | |
| const rem = Math.ceil((en - now)/86400000); | |
| return rem <= CLOSING_DAYS ? "closing" : "open"; | |
| } | |
| function daysLeft(r){ return Math.ceil((dayEnd(r.entryEnd) - today())/86400000); } | |
| // One plain-language line answering "can I still get a spot in this race?" | |
| function availability(r){ | |
| const s = statusOf(r); | |
| if(s==="full") return { | |
| s, icon:"🚫", | |
| text:`満員 FULL — capacity reached${r.capacity?` (${r.capacity.toLocaleString()} runners)`:""}, no spots left` | |
| }; | |
| if(s==="closed") return { | |
| s, icon:"⛔", | |
| text:`Entry closed — ${r.lottery?"lottery":"deadline"} ended ${fmtDate(r.entryEnd)} (not marked full)` | |
| }; | |
| if(s==="upcoming") return { | |
| s, icon:"🕒", | |
| text:`Not open yet — entry opens ${fmtDate(r.entryStart)}` | |
| }; | |
| const n = Math.max(daysLeft(r),0); | |
| if(s==="closing") return { | |
| s, icon:"⚠️", | |
| text:`Spots still available — closes in ${n} day${n===1?"":"s"} (${fmtDate(r.entryEnd)})` | |
| }; | |
| return { s, icon:"✅", text:`Spots available — ${n} days left to enter` }; | |
| } | |
| // ---- map ---- | |
| const map = L.map("map",{zoomControl:true,scrollWheelZoom:true}).setView([36.2,138.6],5); | |
| L.control.zoom({position:"topright"}); | |
| L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{ | |
| maxZoom:14, | |
| attribution:'© OpenStreetMap' | |
| }).addTo(map); | |
| // nice base style | |
| document.querySelector(".leaflet-control-attribution").style.background="rgba(15,20,25,.7)"; | |
| document.querySelector(".leaflet-control-attribution").style.color="#9fb0c0"; | |
| const markers = {}; | |
| const statusColor = (s)=> STATUS[s].color; | |
| function raceIcon(s){ | |
| const c = STATUS[s].color; | |
| // full races get an ✕ so "no spots left" is readable straight off the map | |
| const glyph = s==="full" | |
| ? `<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font:900 9px/1 sans-serif;color:#0f1419">\u2715</span>` | |
| : ""; | |
| return L.divIcon({ | |
| className:"ricon", | |
| html:`<span style="position:relative;display:block;width:16px;height:16px;border-radius:50%;background:${c};border:3px solid #0f1419;box-shadow:0 0 0 2px ${c}55">${glyph}</span>`, | |
| iconSize:[16,16], iconAnchor:[8,8], popupAnchor:[0,-10] | |
| }); | |
| } | |
| function popupHtml(r,s){ | |
| const av = availability(r); | |
| const badge = `<span class="badge ${s}">${STATUS[s].label}</span>`; | |
| const links = (r.official?`<a href="${r.official}" target="_blank" rel="noopener">公式サイト</a> · `:'') + `<a href="${r.runnet}" target="_blank" rel="noopener">RUNNET</a>`; | |
| const kv=(label,val)=>val?`<div style="font-size:11.5px;color:#9fb0c0;margin-top:3px"><b style="color:#cdd9e5">${label}:</b> ${val}</div>`:''; | |
| return `<div class="popup"> | |
| <div class="ptitle">${r.name}</div> | |
| <div class="pplace">${r.prefecture} ${r.city}${r.lottery?' · 抽選 lottery':''}</div> | |
| <div class="pdate"><b>${fmtJP(r.date)}</b> (${r.weekday})</div> | |
| ${badge} | |
| <div class="avail ${av.s}" style="margin-top:6px">${av.icon} ${av.text}</div> | |
| <div style="margin-top:6px;font-size:12px;color:#9fb0c0">Entry: ${fmtDate(r.entryStart)} – ${fmtDate(r.entryEnd)}${r.capacity?` · cap ${r.capacity.toLocaleString()}`:''}</div> | |
| ${kv('Profile',r.profile)} | |
| ${kv('Climate',r.clim)} | |
| ${r.vibe?`<div style="font-size:11.5px;line-height:1.45;color:#9fb0c0;margin-top:4px">${r.vibe}</div>`:''} | |
| ${r.elevNote?`<div style="font-size:11px;line-height:1.4;color:#7b8a99;margin-top:4px">⛰ ${r.elevNote}</div>`:''} | |
| <div style="margin-top:6px">${links}</div> | |
| </div>`; | |
| } | |
| RACES.forEach(r=>{ | |
| const s = statusOf(r); | |
| const m = L.marker([r.lat,r.lng],{icon:raceIcon(s)}).bindPopup(popupHtml(r,s)); | |
| m.raceId = r.id; | |
| m.addTo(map); | |
| markers[r.id] = m; | |
| }); | |
| // ---- filters state ---- | |
| let state = { | |
| search:"", | |
| statuses:new Set(STATUS_KEYS), | |
| month:"all", | |
| regions:new Set(REGIONS.map(r=>r.id)), | |
| difficulties:new Set(DIFFICULTY.map(d=>d.id)), | |
| enterableOnly:false, | |
| hideSoldOut:false, | |
| lotteryOnly:false, | |
| sortAsc:true, | |
| }; | |
| // ---- build filter UI ---- | |
| const statsEl = document.getElementById("stats"); | |
| function renderStats(){ | |
| const counts = {}; | |
| STATUS_KEYS.forEach(k=>counts[k]=0); | |
| RACES.forEach(r=>counts[statusOf(r)]++); | |
| const enterable = ENTERABLE.reduce((a,k)=>a+counts[k],0); | |
| const cell = k => `<div class="stat"><div class="n" style="color:${STATUS[k].color}">${counts[k]}</div><div class="l">${STATUS[k].short}</div></div>`; | |
| statsEl.innerHTML = | |
| `<div class="stat wide hero"><div class="n" style="color:#22c55e">${enterable}<span class="of"> / ${RACES.length}</span></div>`+ | |
| `<div class="l">races you can still enter</div></div>`+ | |
| ["open","closing","full","closed"].map(cell).join("")+ | |
| (counts.upcoming ? `<div class="stat wide"><div class="n" style="color:${STATUS.upcoming.color}">${counts.upcoming}</div><div class="l">Entry not open yet</div></div>` : ""); | |
| } | |
| renderStats(); | |
| const sfEl = document.getElementById("statusFilters"); | |
| STATUS_KEYS.forEach(s=>{ | |
| const b=document.createElement("button"); | |
| b.className="chip active"; | |
| b.innerHTML=`<span class="dot" style="background:${STATUS[s].color}"></span>${STATUS[s].label}`; | |
| b.onclick=()=>{ | |
| if(state.statuses.has(s)){state.statuses.delete(s);b.classList.remove("active");} | |
| else{state.statuses.add(s);b.classList.add("active");} | |
| update(); | |
| }; | |
| sfEl.appendChild(b); | |
| }); | |
| const rfEl = document.getElementById("regionFilters"); | |
| const dfEl = document.getElementById("diffFilters"); | |
| if(dfEl){ | |
| DIFFICULTY.forEach(dd=>{ | |
| const b=document.createElement("button"); | |
| b.className="chip active"; | |
| b.textContent=dd.label; | |
| b.onclick=()=>{ | |
| if(state.difficulties.has(dd.id)){state.difficulties.delete(dd.id);b.classList.remove("active");} | |
| else{state.difficulties.add(dd.id);b.classList.add("active");} | |
| update(); | |
| }; | |
| dfEl.appendChild(b); | |
| }); | |
| } | |
| REGIONS.forEach(rg=>{ | |
| const b=document.createElement("button"); | |
| b.className="chip active"; | |
| b.textContent=rg.label; | |
| b.onclick=()=>{ | |
| if(state.regions.has(rg.id)){state.regions.delete(rg.id);b.classList.remove("active");} | |
| else{state.regions.add(rg.id);b.classList.add("active");} | |
| update(); | |
| }; | |
| rfEl.appendChild(b); | |
| }); | |
| document.getElementById("search").addEventListener("input",e=>{state.search=e.target.value.toLowerCase();update();}); | |
| document.getElementById("monthFilter").addEventListener("change",e=>{state.month=e.target.value;update();}); | |
| document.getElementById("enterableOnly").addEventListener("change",e=>{state.enterableOnly=e.target.checked;update();}); | |
| document.getElementById("hideSoldOut").addEventListener("change",e=>{state.hideSoldOut=e.target.checked;update();}); | |
| document.getElementById("lotteryOnly").addEventListener("change",e=>{state.lotteryOnly=e.target.checked;update();}); | |
| const sortBtn=document.getElementById("sortDate"); | |
| sortBtn.onclick=()=>{state.sortAsc=!state.sortAsc;sortBtn.textContent="Sort: Date "+(state.sortAsc?"↑":"↓");update();}; | |
| // legend | |
| const legendEl=document.getElementById("legend"); | |
| legendEl.innerHTML = STATUS_KEYS.map(s=> | |
| `<div class="row"><span class="dot${s==="full"?" x":""}" style="background:${STATUS[s].color}"></span>${STATUS[s].label}</div>` | |
| ).join(""); | |
| // ---- filtering ---- | |
| function matches(r){ | |
| const s=statusOf(r); | |
| if(state.enterableOnly && !ENTERABLE.includes(s)) return false; | |
| if(state.hideSoldOut && s==="full") return false; | |
| if(state.lotteryOnly && !r.lottery) return false; | |
| if(!state.statuses.has(s)) return false; | |
| if(state.month!=="all" && String(Number(r.date.slice(5,7)))!==state.month) return false; | |
| if(!state.regions.has(r.region)) return false; | |
| if(!state.difficulties.has(difficultyOf(r))) return false; | |
| if(state.search){ | |
| const hay=(r.name+" "+r.nameEn+" "+r.prefecture+" "+r.city+" "+r.region).toLowerCase(); | |
| if(!hay.includes(state.search)) return false; | |
| } | |
| return true; | |
| } | |
| function update(){ | |
| const visible = RACES.filter(matches).sort((a,b)=> state.sortAsc? (a.date<b.date?-1:a.date>b.date?1:0) : (a.date>b.date?-1:a.date<b.date?1:0)); | |
| // markers | |
| RACES.forEach(r=>{ | |
| const show = visible.includes(r); | |
| if(show && !map.hasLayer(markers[r.id])) markers[r.id].addTo(map); | |
| if(!show && map.hasLayer(markers[r.id])) map.removeLayer(markers[r.id]); | |
| }); | |
| renderCards(visible); | |
| document.getElementById("count").textContent = `(${visible.length} / ${RACES.length})`; | |
| document.getElementById("empty").style.display = visible.length? "none":"block"; | |
| } | |
| function renderCards(list){ | |
| const cards=document.getElementById("cards"); | |
| cards.innerHTML = list.map(r=>{ | |
| const s=statusOf(r); | |
| const av = availability(r); | |
| const badge = `<span class="badge ${s}">${STATUS[s].label}</span>`; | |
| const tags = []; | |
| if(r.lottery) tags.push(`<span class="tag lot">抽選 lottery</span>`); | |
| if(r.capacity) tags.push(`<span class="tag">cap ${r.capacity.toLocaleString()}</span>`); | |
| if(r.fee && r.fee!=="—") tags.push(`<span class="tag">${r.fee}</span>`); | |
| tags.push(`<span class="tag">${r.limitTime}</span>`); | |
| return `<div class="card${s==="full"?" is-full":""}" data-id="${r.id}"> | |
| <div class="top"> | |
| <div> | |
| <div class="name">${r.name}</div> | |
| <div class="place">${r.prefecture} ${r.city}</div> | |
| </div> | |
| ${badge} | |
| </div> | |
| <div class="date">${fmtJP(r.date)} · ${r.weekday}</div> | |
| <div class="avail ${av.s}">${av.icon} ${av.text}</div> | |
| <div class="row"><b>Entry:</b> ${fmtDate(r.entryStart)} → ${fmtDate(r.entryEnd)}</div> | |
| ${r.profile?`<div class="row"><b>Profile:</b> ${r.profile}</div>`:''} | |
| ${r.terrain?`<div class="row"><b>Terrain:</b> ${r.terrain}</div>`:''} | |
| ${r.clim?`<div class="row" style="color:#7bc4f0"><b>🌡 Climate:</b> ${r.clim}</div>`:''} | |
| ${r.vibe?`<div class="row" style="font-size:11.5px;line-height:1.45;color:#cdd9e5"><b>Vibe:</b> ${r.vibe}</div>`:''} | |
| ${r.elevNote?`<div class="row" style="font-size:11px;line-height:1.4;color:#7b8a99">⛰ ${r.elevNote}</div>`:''} | |
| ${r.note?`<div class="row" style="font-size:11px;line-height:1.4;color:#9fb0c0">${r.note}</div>`:''} | |
| <div class="tags">${tags.join("")}</div> | |
| </div>`; | |
| }).join(""); | |
| } | |
| // click card -> focus map | |
| document.getElementById("cards").addEventListener("click",e=>{ | |
| const c=e.target.closest(".card"); if(!c) return; | |
| const r=RACES.find(x=>x.id===c.dataset.id); if(!r) return; | |
| map.setView([r.lat,r.lng],10,{animate:true}); | |
| markers[r.id].openPopup(); | |
| }); | |
| update(); |