"use strict"; 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" ? `\u2715` : ""; return L.divIcon({ className:"ricon", html:`${glyph}`, iconSize:[16,16], iconAnchor:[8,8], popupAnchor:[0,-10] }); } function popupHtml(r,s){ const av = availability(r); const badge = `${STATUS[s].label}`; const links = (r.official?`公式サイト · `:'') + `RUNNET`; const kv=(label,val)=>val?`
${label}: ${val}
`:''; return ``; } 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 => `
${counts[k]}
${STATUS[k].short}
`; statsEl.innerHTML = `
${enterable} / ${RACES.length}
`+ `
races you can still enter
`+ ["open","closing","full","closed"].map(cell).join("")+ (counts.upcoming ? `
${counts.upcoming}
Entry not open yet
` : ""); } renderStats(); const sfEl = document.getElementById("statusFilters"); STATUS_KEYS.forEach(s=>{ const b=document.createElement("button"); b.className="chip active"; b.innerHTML=`${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=> `
${STATUS[s].label}
` ).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.dateb.date?1:0) : (a.date>b.date?-1:a.date{ 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 = `${STATUS[s].label}`; const tags = []; if(r.lottery) tags.push(`抽選 lottery`); if(r.capacity) tags.push(`cap ${r.capacity.toLocaleString()}`); if(r.fee && r.fee!=="—") tags.push(`${r.fee}`); tags.push(`${r.limitTime}`); return `
${r.name}
${r.prefecture} ${r.city}
${badge}
${fmtJP(r.date)} · ${r.weekday}
${av.icon} ${av.text}
Entry: ${fmtDate(r.entryStart)} → ${fmtDate(r.entryEnd)}
${r.profile?`
Profile: ${r.profile}
`:''} ${r.terrain?`
Terrain: ${r.terrain}
`:''} ${r.clim?`
🌡 Climate: ${r.clim}
`:''} ${r.vibe?`
Vibe: ${r.vibe}
`:''} ${r.elevNote?`
⛰ ${r.elevNote}
`:''} ${r.note?`
${r.note}
`:''}
${tags.join("")}
`; }).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();