Kalkulator Single Speed
Kalkulator optymalnego przełożenia Single Speed
Chcesz wiedzieć, jaka tylna zębatka będzie najlepsza dla Twojego roweru Single Speed?
Skorzystaj z naszego kalkulatora i sprawdź, jakie przełożenie sprawdzi się najlepiej w zależności od trasy, którą planujesz pokonać.
Jak to działa?
- Wprowadź dane swojego roweru i wagi kolarza – wystarczy zrobić to raz.
- Dodaj odcinki trasy:
- Dystans w kilometrach,
- Rodzaj terenu (płaski, pagórkowaty, stromy),
- Średnie nachylenie w procentach.
- Możesz dodać dowolną liczbę odcinków – np. 10 km płaskiego, 5 km pod górę, 3 km stromego podjazdu.
- Podaj liczbę zębów przedniej zębatki (np. 42).
- Kalkulator wyliczy optymalną tylną zębatkę oraz poda najbliższy dostępny wolnobieg (np. 16T, 18T, 20T).
Algorytm korzysta z uproszczonego modelu, który uwzględnia:
- średnie nachylenie trasy,
- udział poszczególnych rodzajów terenu w całej trasie,
- wagę roweru i kolarza (do korekty trudności jazdy).
FAQ
Czy kalkulator poda mi jedną idealną wartość?
Nie do końca – poda wartość obliczoną oraz najbliższy dostępny wolnobieg. To sugestia, a nie absolutna odpowiedź.
Co jeśli jeżdżę w różnych warunkach (miasto + góry)?
Możesz dodać różne trasy do tabeli i sprawdzić, jakie przełożenia są sugerowane w każdym scenariuszu.
Czy wynik nadaje się do ścigania?
Kalkulator jest przybliżonym narzędziem – dobór przełożenia do wyścigów zależy też od stylu jazdy, kadencji i preferencji.
Jakie przełożenie jest najczęściej wybierane?
Na szosie popularne są zestawy typu 42/16 lub 46/17. W trudniejszym terenie warto rozważyć większą tylną zębatkę (np. 18–20T).
Spróbuj teraz!
Wprowadź swoje dane i zobacz, jakie przełożenie Single Speed będzie najlepsze na Twoją trasę.
| # | Dystans (km) | Teren | Nachylenie (%) |
|---|
Wynik pojawi się tutaj...
Kod po stronie przeglądarki
<div id="app">
<!-- Parametry roweru i kolarza -->
<div class="card mb-3">
<div class="card-header">Parametry roweru i kolarza</div>
<div class="card-body">
<div class="row g-2">
<div class="col-sm-2">
<label for="bikeWeight" class="form-label small">Waga roweru (kg)</label>
<input type="number" class="form-control" id="bikeWeight" value="8.5">
</div>
<div class="col-sm-2">
<label for="riderWeight" class="form-label small">Waga kolarza (kg)</label>
<input type="number" class="form-control" id="riderWeight" value="75">
</div>
<div class="col-sm-2">
<label for="wheelSize" class="form-label small">Średnica koła (mm)</label>
<input type="number" class="form-control" id="wheelSize" value="700">
</div>
<div class="col-sm-2">
<label for="tireWidth" class="form-label small">Szerokość opony (mm)</label>
<input type="number" class="form-control" id="tireWidth" value="28">
</div>
<div class="col-sm-2">
<label for="bikeType" class="form-label small">Typ roweru</label>
<select class="form-select" id="bikeType">
<option value="road">Szosa</option>
<option value="gravel">Szuter</option>
<option value="urban">Miasto</option>
</select>
</div>
</div>
</div>
</div>
<!-- Tabela odcinków -->
<div class="card mb-3">
<div class="card-header">Odcinki trasy</div>
<div class="card-body">
<div class="row mb-2">
<div class="col-sm-3">
<input type="number" step="0.1" class="form-control" id="distance" placeholder="Dystans (km)" aria-label="Dystans trasy">
</div>
<div class="col-sm-3">
<select class="form-select" id="terrain" aria-label="Rodzaj terenu">
<option value="flat">Płaski</option>
<option value="hilly">Pagórkowaty</option>
<option value="steep">Stromy</option>
</select>
</div>
<div class="col-sm-3">
<input type="number" step="0.1" class="form-control" id="slope" placeholder="Nachylenie (%)" aria-label="Nachylenie terenu">
</div>
<div class="col-sm-3">
<button class="btn btn-primary w-100" id="addSegmentBtn">Dodaj odcinek</button>
</div>
</div>
<table class="table table-sm table-striped">
<thead class="table-dark">
<tr>
<th>#</th>
<th>Dystans (km)</th>
<th>Teren</th>
<th>Nachylenie (%)</th>
</tr>
</thead>
<tbody id="segmentsBody"></tbody>
</table>
<div class="d-flex gap-2">
<button class="btn btn-secondary" id="loadDefaultSegments">Wczytaj przykładową trasę</button>
<button class="btn btn-danger" id="clearSegments">Wyczyść odcinki</button>
</div>
</div>
</div>
<!-- Wykres trasy -->
<div class="card mb-3">
<div class="card-header">Profil trasy</div>
<div class="card-body">
<canvas id="routeChart" height="50"></canvas>
<div class="small text-muted mt-2">Losowy profil trasy na bazie danych z tabeli. Uwzględnia procentowe nachylenie terenu i symuluje pagórkowaty albo górzysty teren.</div>
</div>
</div>
<!-- Obliczenia przełożenia -->
<div class="card">
<div class="card-body">
<div class="row align-items-center">
<div class="col-sm-4">
<label for="frontTeeth" class="form-label small">Zęby przedniej zębatki</label>
<input type="number" class="form-control" id="frontTeeth" value="42">
</div>
<div class="col-sm-4">
<button class="btn btn-success mt-4" id="calcBtn">Oblicz optymalne przełożenie</button>
</div>
</div>
<div class="mt-3">
<h3 class="h4" id="result">Wynik pojawi się tutaj...</h3>
<pre id="details" class="rouded p-3 border rounded" style="display: none;"></pre>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
let segmentCounter = 0;
const TERRAIN_LABEL = { flat: 'Płaski', hilly: 'Pagórkowaty', steep: 'Stromy' };
const DEFAULT_SEGMENTS = [
{ distance: 10, terrain: 'flat', slope: 0 },
{ distance: 5, terrain: 'hilly', slope: 4 },
{ distance: 3, terrain: 'steep', slope: 8 },
{ distance: 12, terrain: 'flat', slope: 1 }
];
// Chart.js init
const ctx = document.getElementById('routeChart').getContext('2d');
let routeChart = new Chart(ctx, {
type: 'line',
data: { datasets: [{ label: 'Wysokość (m)', data: [], borderColor: 'rgba(0,123,255,1)', backgroundColor: 'rgba(0,123,255,0.12)', fill: true, tension: 0.25, pointRadius: 0 }] },
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: (items) => `Km: ${items[0].parsed.x.toFixed(2)}`,
label: (item) => `Wysokość: ${item.parsed.y.toFixed(1)} m`
}
}
},
scales: {
x: { type: 'linear', title: { display: true, text: 'Kilometry (km)' } },
y: { title: { display: true, text: 'Wysokość (m)' }, beginAtZero: true }
}
}
});
function updateChart() {
const rows = Array.from(document.querySelectorAll('#segmentsBody tr'));
if (rows.length === 0) {
routeChart.data.datasets[0].data = [];
routeChart.update();
return;
}
// oblicz łączny dystans
let totalDistance = 0;
rows.forEach(r => totalDistance += parseFloat(r.dataset.distance));
// parametry próbkowania
const sampleStepKm = 1; // 0.1 km = 100 m
let points = [];
let cumDistance = 0;
let currentElevation = 0; // startujemy od 0 m
// dodaj punkt startowy
points.push({ x: 0, y: 0 });
rows.forEach(row => {
const segDist = parseFloat(row.dataset.distance);
const slope = parseFloat(row.dataset.slope); // w procentach, może być ujemne
// ile próbek w segmencie (co sampleStepKm rozdzielimy liniowo)
const n = Math.max(1, Math.ceil(segDist / sampleStepKm));
const stepKm = segDist / n; // dokładny krok, tak by ostatni punkt pokrywał koniec segmentu
for (let i = 0; i < n; i++) {
let rand = Math.random();
let sgn = rand < 0.3 ? -1 : (rand < 0.7 ? 0 : 1);
// krok do środka/końca podsegmentu
cumDistance += stepKm;
// zmian wysokości = slope (%) * horizontal distance (m)
// slope% = rise / run * 100 => rise = slope/100 * run
const rise = (slope / 100) * (stepKm * 1000); // w metrach
currentElevation += sgn * rise;
points.push({ x: Number(cumDistance.toFixed(3)), y: Number(currentElevation.toFixed(3)) });
}
});
// upewnij się, że ostatni punkt ma x = totalDistance (zaokrąglenia)
if (points.length > 1) points[points.length -1].x = Number(totalDistance.toFixed(3));
// jeśli minimalna wysokość < 0 przesuwamy cały profil w górę tak, aby min = 0
const minY = Math.min(...points.map(p => p.y));
if (minY < 0) {
const shift = Math.abs(minY);
points = points.map(p => ({ x: p.x, y: Number((p.y + shift).toFixed(3)) }));
}
const maxY = Math.max(...points.map(p => p.y));
routeChart.data.datasets[0].data = points;
routeChart.options.scales.x.min = 0;
routeChart.options.scales.x.max = Math.max(totalDistance, 1);
routeChart.options.scales.y.min = 0;
routeChart.options.scales.y.max = Math.ceil(maxY + Math.max(10, maxY*0.05));
routeChart.update();
}
function addSegment(data) {
const distance = data ? parseFloat(data.distance) : parseFloat(document.getElementById('distance').value);
const terrain = data ? data.terrain : document.getElementById('terrain').value;
const slope = data ? parseFloat(data.slope) : parseFloat(document.getElementById('slope').value);
if (Number.isNaN(distance) || Number.isNaN(slope)) {
alert('Podaj poprawne wartości dystansu i nachylenia.');
return;
}
segmentCounter++;
const tr = document.createElement('tr');
tr.dataset.distance = distance;
tr.dataset.terrain = terrain;
tr.dataset.slope = slope;
tr.innerHTML = `
<td>${segmentCounter}</td>
<td>${distance.toFixed(1)}</td>
<td>${TERRAIN_LABEL[terrain]}</td>
<td>${slope.toFixed(1)}</td>
`;
document.getElementById('segmentsBody').appendChild(tr);
updateChart();
}
function loadDefaultSegments() {
clearSegments();
DEFAULT_SEGMENTS.forEach(seg => addSegment(seg));
}
function clearSegments() {
document.getElementById('segmentsBody').innerHTML = '';
segmentCounter = 0;
updateChart();
}
function calculateGear() {
const frontTeeth = parseInt(document.getElementById('frontTeeth').value, 10);
const rows = Array.from(document.querySelectorAll('#segmentsBody tr'));
if (!frontTeeth || frontTeeth <= 0) {
alert('Wprowadź poprawną liczbę zębów przedniej zębatki.');
return;
}
if (rows.length === 0) {
alert('Dodaj odcinki trasy.');
return;
}
// dane roweru i kolarza
const bikeWeight = parseFloat(document.getElementById('bikeWeight').value) || 0;
const riderWeight = parseFloat(document.getElementById('riderWeight').value) || 0;
const wheelSize = parseFloat(document.getElementById('wheelSize').value) || 0;
const tireWidth = parseFloat(document.getElementById('tireWidth').value) || 0;
const bikeType = document.getElementById('bikeType').value;
const totalWeight = bikeWeight + riderWeight;
const wheelDiameter = wheelSize + 2 * tireWidth; // mm
const wheelCircumference = Math.PI * wheelDiameter / 1000; // metry
// Średnie ważone parametry trasy
let totalDistance = 0, weightedSlope = 0, terrainFactor = 0;
rows.forEach(row => {
const d = parseFloat(row.dataset.distance);
const slope = parseFloat(row.dataset.slope);
const terr = row.dataset.terrain;
totalDistance += d;
weightedSlope += d * slope;
if (terr === 'flat') terrainFactor += d * 1.0;
if (terr === 'hilly') terrainFactor += d * 1.2;
if (terr === 'steep') terrainFactor += d * 1.5;
});
const avgSlope = weightedSlope / totalDistance;
const avgTerrainFactor = terrainFactor / totalDistance;
// prosty model przełożenia
let basePreference = 2.7;
basePreference /= (1 + avgSlope/10);
basePreference /= avgTerrainFactor;
// korekta wagą
if (totalWeight > 100) basePreference *= 0.9;
else if (totalWeight < 70) basePreference *= 1.05;
// korekta typem roweru
if (bikeType === 'road') basePreference *= 1.05;
if (bikeType === 'urban') basePreference *= 0.95;
// uwzględnienie obwodu koła – lekkie skalowanie
basePreference *= (wheelCircumference / 2.1); // normalizacja względem ~2.1 m (700x28)
let rearTeeth = Math.round(frontTeeth / basePreference);
rearTeeth = Math.max(11, Math.min(40, rearTeeth));
const suggestions = [11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,30,32,34,36,38,40];
let nearest = suggestions.reduce((a,b) => Math.abs(b - rearTeeth) < Math.abs(a - rearTeeth) ? b : a);
document.getElementById('result').innerText = `Optymalna tylna zębatka: ${rearTeeth} — sugerowany wolnobieg: ${nearest}`;
document.getElementById('details').style.display = 'block';
document.getElementById('details').innerText = `Średnie nachylenie: ${avgSlope.toFixed(1)}%\nŚredni współczynnik terenu: ${avgTerrainFactor.toFixed(2)}\nObwód koła: ${wheelCircumference.toFixed(2)} m\nWaga całkowita: ${totalWeight.toFixed(1)} kg`;
}
// Eventy
document.getElementById('addSegmentBtn').addEventListener('click', () => addSegment());
document.getElementById('loadDefaultSegments').addEventListener('click', () => loadDefaultSegments());
document.getElementById('clearSegments').addEventListener('click', () => clearSegments());
document.getElementById('calcBtn').addEventListener('click', () => calculateGear());
// Na start przykładowa trasa
loadDefaultSegments();
</script>
Kod po stronie serwera
Brak kodu serwera
Ta aplikacja działa wyłącznie w przeglądarce i nie korzysta z kodu po stronie serwera.
Licencja
## BSD-3-Clause License Agreement
BSD-3-Clause
Сopyright (c) 2026 Dariusz Rorat
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.