Jikkii's picture
Split "full (満員)" from "closed (deadline)" into its own status
8cffa3a
Raw History Blame Contribute Delete
12.6 kB
"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:'&copy; 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();