"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();