fix planner toggle active state, improve space defaults

- Toggle buttons (Indoor/Outdoor, Rent/Buy) now visually update
  their active state on click
- Space requirement sliders start from minimum court size
  (200m² double, 120m² single) instead of 0
- Defaults updated to court + 2m buffer (336/240/312/216 m²)
- Reference dimensions panel shows standard court sizes
- Chart.js font updated from JetBrains Mono to Commit Mono

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-16 18:06:03 +01:00
parent 11999bdc5d
commit 7cb41d91f2
4 changed files with 49 additions and 14 deletions

View File

@@ -19,10 +19,10 @@ DEFAULTS = {
"own": "rent", "own": "rent",
"dblCourts": 4, "dblCourts": 4,
"sglCourts": 2, "sglCourts": 2,
"sqmPerDblHall": 330, "sqmPerDblHall": 336,
"sqmPerSglHall": 220, "sqmPerSglHall": 240,
"sqmPerDblOutdoor": 300, "sqmPerDblOutdoor": 312,
"sqmPerSglOutdoor": 200, "sqmPerSglOutdoor": 216,
"ratePeak": 50, "ratePeak": 50,
"rateOffPeak": 35, "rateOffPeak": 35,
"rateSingle": 30, "rateSingle": 30,

View File

@@ -279,6 +279,30 @@
-moz-appearance: textfield; -moz-appearance: textfield;
} }
/* ── Space reference facts ── */
.space-facts {
margin-top: 10px;
padding: 10px 12px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 11px;
color: var(--txt-3);
}
.space-facts__title {
font-weight: 600;
color: var(--txt-2);
margin-bottom: 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.space-facts__item {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
/* ── Toggle buttons ── */ /* ── Toggle buttons ── */
.toggle-group { .toggle-group {
display: flex; display: flex;

View File

@@ -2,7 +2,7 @@
const S = { const S = {
venue:'indoor', own:'rent', venue:'indoor', own:'rent',
dblCourts:4, sglCourts:2, dblCourts:4, sglCourts:2,
sqmPerDblHall:330, sqmPerSglHall:220, sqmPerDblOutdoor:300, sqmPerSglOutdoor:200, sqmPerDblHall:336, sqmPerSglHall:240, sqmPerDblOutdoor:312, sqmPerSglOutdoor:216,
ratePeak:50, rateOffPeak:35, rateSingle:30, ratePeak:50, rateOffPeak:35, rateSingle:30,
peakPct:40, hoursPerDay:16, daysPerMonthIndoor:29, daysPerMonthOutdoor:25, peakPct:40, hoursPerDay:16, daysPerMonthIndoor:29, daysPerMonthOutdoor:25,
bookingFee:10, utilTarget:40, bookingFee:10, utilTarget:40,
@@ -173,12 +173,19 @@ function rebuildSpaceInputs(){
const isIn = S.venue==='indoor'; const isIn = S.venue==='indoor';
let h = ''; let h = '';
if(isIn){ if(isIn){
h += slider('sqmPerDblHall','Hall m\u00B2 per Double Court',0,600,10,fN,'Total hall space needed per double court. Includes court (200m\u00B2), safety zones, circulation, and minimum clearances. Standard: 300\u2013350m\u00B2.')+ h += slider('sqmPerDblHall','Hall m\u00B2 per Double Court',200,600,10,fN,'Total hall space needed per double court. Includes court (200m\u00B2), safety zones, circulation, and minimum clearances. Standard: 300\u2013350m\u00B2.')+
slider('sqmPerSglHall','Hall m\u00B2 per Single Court',0,400,10,fN,'Total hall space needed per single court. Includes court (120m\u00B2), safety zones, and access. Standard: 200\u2013250m\u00B2.'); slider('sqmPerSglHall','Hall m\u00B2 per Single Court',120,400,10,fN,'Total hall space needed per single court. Includes court (120m\u00B2), safety zones, and access. Standard: 200\u2013250m\u00B2.');
} else { } else {
h += slider('sqmPerDblOutdoor','Land m\u00B2 per Double Court',0,500,10,fN,'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280\u2013320m\u00B2.')+ h += slider('sqmPerDblOutdoor','Land m\u00B2 per Double Court',200,500,10,fN,'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280\u2013320m\u00B2.')+
slider('sqmPerSglOutdoor','Land m\u00B2 per Single Court',0,350,10,fN,'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180\u2013220m\u00B2.'); slider('sqmPerSglOutdoor','Land m\u00B2 per Single Court',120,350,10,fN,'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180\u2013220m\u00B2.');
} }
h += '<div class="space-facts">';
h += '<div class="space-facts__title">Reference dimensions</div>';
h += '<div class="space-facts__item"><span>Double court playing area</span><span class="mono">20\u00D710m = 200 m\u00B2</span></div>';
h += '<div class="space-facts__item"><span>Single court playing area</span><span class="mono">20\u00D76m = 120 m\u00B2</span></div>';
h += '<div class="space-facts__item"><span>+ 2m buffer all around</span><span class="mono">24\u00D714m = 336 m\u00B2 / 24\u00D710m = 240 m\u00B2</span></div>';
h += '<div class="space-facts__item"><span>Min. ceiling height (indoor)</span><span class="mono">8\u201310m clear</span></div>';
h += '</div>';
$('#inp-space').innerHTML = h; $('#inp-space').innerHTML = h;
} }
@@ -237,6 +244,10 @@ function buildToggle(id,opts,key){
el.innerHTML = opts.map(o=>`<button data-val="${o.v}" class="toggle-btn ${S[key]===o.v?'toggle-btn--active':''}">${o.l}</button>`).join(''); el.innerHTML = opts.map(o=>`<button data-val="${o.v}" class="toggle-btn ${S[key]===o.v?'toggle-btn--active':''}">${o.l}</button>`).join('');
el.querySelectorAll('button').forEach(b=>b.onclick=()=>{ el.querySelectorAll('button').forEach(b=>b.onclick=()=>{
S[key]=b.dataset.val; S[key]=b.dataset.val;
// Update active state on all buttons in this toggle group
el.querySelectorAll('button').forEach(btn=>{
btn.classList.toggle('toggle-btn--active', btn.dataset.val===S[key]);
});
rebuildSpaceInputs(); rebuildCapexInputs(); rebuildOpexInputs(); bindSliders(); render(); rebuildSpaceInputs(); rebuildCapexInputs(); rebuildOpexInputs(); bindSliders(); render();
}); });
} }
@@ -535,7 +546,7 @@ function renderChart(canvasId,type,data,opts={}){
} else { } else {
defaults.scales = { defaults.scales = {
x:{ticks:{color:'#94A3B8',font:{size:9,family:'Inter'}},grid:{display:false},border:{color:'#E2E8F0'}}, x:{ticks:{color:'#94A3B8',font:{size:9,family:'Inter'}},grid:{display:false},border:{color:'#E2E8F0'}},
y:{ticks:{color:'#94A3B8',font:{size:9,family:'JetBrains Mono'}},grid:{color:'rgba(0,0,0,0.04)'},border:{color:'#E2E8F0'}}, y:{ticks:{color:'#94A3B8',font:{size:9,family:'Commit Mono'}},grid:{color:'rgba(0,0,0,0.04)'},border:{color:'#E2E8F0'}},
}; };
} }
charts[canvasId] = new Chart(ctx,{type,data,options:deepMerge(defaults,opts)}); charts[canvasId] = new Chart(ctx,{type,data,options:deepMerge(defaults,opts)});

View File

@@ -179,7 +179,7 @@ class TestCalcDefaultScenario:
def test_sqm_is_hall(self, d): def test_sqm_is_hall(self, d):
# Indoor venue → sqm is hallSqm # Indoor venue → sqm is hallSqm
expected = 4 * 330 + 2 * 220 + 200 + 6 * 20 expected = 4 * 336 + 2 * 240 + 200 + 6 * 20
assert d["hallSqm"] == expected assert d["hallSqm"] == expected
assert d["sqm"] == expected assert d["sqm"] == expected
@@ -377,7 +377,7 @@ class TestCalcOutdoorRent:
return calc(default_state(venue="outdoor", own="rent")) return calc(default_state(venue="outdoor", own="rent"))
def test_sqm_is_outdoor_land(self, d): def test_sqm_is_outdoor_land(self, d):
expected = 4 * 300 + 2 * 200 + 100 expected = 4 * 312 + 2 * 216 + 100
assert d["outdoorLandSqm"] == expected assert d["outdoorLandSqm"] == expected
assert d["sqm"] == expected assert d["sqm"] == expected
@@ -764,8 +764,8 @@ class TestCalcRegression:
assert d["totalCourts"] == 6 assert d["totalCourts"] == 6
def test_hall_sqm(self, d): def test_hall_sqm(self, d):
# 4*330 + 2*220 + 200 + 6*20 = 2080 # 4*336 + 2*240 + 200 + 6*20 = 2144
assert d["hallSqm"] == 2080 assert d["hallSqm"] == 2144
def test_opex_value(self, d): def test_opex_value(self, d):
# Rent + Insurance + Electricity + Heating + Water + Maintenance + # Rent + Insurance + Electricity + Heating + Water + Maintenance +