| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Interactive Bike Geometry Visualizer</title> |
| <style> |
| html, body { |
| height: 100%; |
| margin: 0; |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| background-color: #f8f9fa; |
| color: #333; |
| } |
| body { |
| display: flex; |
| flex-direction: column; |
| padding: 20px; |
| box-sizing: border-box; |
| min-height: 100vh; |
| } |
| h1 { |
| text-align: center; |
| margin-bottom: 10px; |
| flex-shrink: 0; |
| color: #2c3e50; |
| font-weight: 600; |
| font-size: 2.2rem; |
| text-shadow: 1px 1px 1px rgba(0,0,0,0.05); |
| } |
| |
| .tool-description { |
| text-align: center; |
| margin: 0 auto 25px auto; |
| max-width: 600px; |
| color: #7f8c8d; |
| font-size: 1.1rem; |
| line-height: 1.5; |
| } |
| |
| |
| #main-layout { |
| display: flex; |
| flex-direction: row; |
| width: 100%; |
| flex-grow: 1; |
| gap: 30px; |
| align-items: flex-start; |
| max-width: 1800px; |
| margin: 0 auto; |
| } |
| |
| |
| #bike-container { |
| flex: 1 1 65%; |
| max-width: 1200px; |
| border: none; |
| background-color: #fff; |
| border-radius: 12px; |
| box-shadow: 0 8px 24px rgba(0,0,0,0.08); |
| padding: 20px; |
| align-self: stretch; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| transition: all 0.3s ease; |
| } |
| #bike-container:hover { |
| box-shadow: 0 12px 32px rgba(0,0,0,0.12); |
| } |
| svg { |
| display: block; |
| width: 100%; |
| height: auto; |
| max-height: 95%; |
| } |
| |
| |
| #controls { |
| flex: 0 0 380px; |
| padding: 25px; |
| background-color: #fff; |
| border-radius: 12px; |
| box-shadow: 0 8px 24px rgba(0,0,0,0.08); |
| |
| display: grid; |
| grid-template-columns: auto 1fr auto; |
| gap: 14px 18px; |
| align-items: center; |
| align-content: start; |
| |
| max-height: calc(100vh - 120px); |
| overflow-y: auto; |
| overflow-x: hidden; |
| scrollbar-width: thin; |
| scrollbar-color: #ccc #f8f9fa; |
| } |
| #controls::-webkit-scrollbar { |
| width: 8px; |
| } |
| #controls::-webkit-scrollbar-track { |
| background: #f8f9fa; |
| } |
| #controls::-webkit-scrollbar-thumb { |
| background-color: #ccc; |
| border-radius: 10px; |
| border: 2px solid #f8f9fa; |
| } |
| |
| |
| .frame-tube { |
| stroke: #2980b9; |
| stroke-width: 8; |
| stroke-linecap: round; |
| filter: drop-shadow(0px 2px 3px rgba(0,0,0,0.25)); |
| transition: stroke 0.3s ease; |
| } |
| |
| #seat-tube { stroke: #2980b9; } |
| #top-tube { stroke: #3498db; } |
| #down-tube { stroke: #2c3e50; } |
| #chainstay { stroke: #34495e; } |
| #seatstay { stroke: #3498db; } |
| .wheel { |
| stroke: #34495e; |
| stroke-width: 6; |
| fill: none; |
| filter: drop-shadow(0px 3px 4px rgba(0,0,0,0.2)); |
| transition: all 0.3s ease; |
| } |
| .spokes { |
| stroke: #bdc3c7; |
| stroke-width: 1.2; |
| transition: stroke 0.3s ease; |
| } |
| .component { |
| stroke: #7f8c8d; |
| stroke-width: 5; |
| stroke-linecap: round; |
| filter: drop-shadow(0px 2px 2px rgba(0,0,0,0.15)); |
| transition: stroke 0.3s ease; |
| } |
| |
| |
| #fork { stroke: #8e44ad; } |
| #head-tube { stroke: #16a085; } |
| #stem { stroke: #d35400; } |
| #handlebar { stroke: #c0392b; } |
| #seatpost { stroke: #27ae60; } |
| |
| .headset-spacer { |
| stroke: #95a5a6; |
| stroke-width: 9; |
| stroke-linecap: butt; |
| } |
| .drivetrain { |
| stroke: #7f8c8d; |
| stroke-width: 5; |
| fill: none; |
| stroke-linecap: round; |
| transition: stroke 0.3s ease; |
| } |
| .pedal { |
| fill: #2c3e50; |
| transition: fill 0.3s ease; |
| } |
| .joint { |
| fill: #e74c3c; |
| stroke: none; |
| filter: drop-shadow(0px 1px 1px rgba(0,0,0,0.2)); |
| } |
| .axle { |
| fill: #3498db; |
| stroke: none; |
| filter: drop-shadow(0px 1px 1px rgba(0,0,0,0.2)); |
| } |
| .saddle { |
| fill: #2c3e50; |
| filter: drop-shadow(0px 2px 3px rgba(0,0,0,0.25)); |
| transition: fill 0.3s ease; |
| } |
| |
| |
| label { |
| text-align: right; |
| font-size: 0.9em; |
| color: #555; |
| font-weight: 500; |
| } |
| |
| input[type="range"] { |
| width: 100%; |
| cursor: grab; |
| -webkit-appearance: none; |
| background: transparent; |
| margin: 7px 0; |
| } |
| |
| input[type="range"]:focus { |
| outline: none; |
| } |
| |
| |
| input[type="range"]::-webkit-slider-runnable-track { |
| width: 100%; |
| height: 6px; |
| cursor: pointer; |
| background: #e0e0e0; |
| border-radius: 3px; |
| border: none; |
| } |
| |
| input[type="range"]::-webkit-slider-thumb { |
| -webkit-appearance: none; |
| height: 18px; |
| width: 18px; |
| border-radius: 50%; |
| background: #3a6ea5; |
| cursor: grab; |
| margin-top: -6px; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.2); |
| transition: background 0.15s ease; |
| } |
| |
| input[type="range"]::-webkit-slider-thumb:hover { |
| background: #2980b9; |
| } |
| |
| input[type="range"]::-webkit-slider-thumb:active { |
| cursor: grabbing; |
| background: #2471a3; |
| } |
| |
| |
| input[type="range"]::-moz-range-track { |
| width: 100%; |
| height: 6px; |
| cursor: pointer; |
| background: #e0e0e0; |
| border-radius: 3px; |
| border: none; |
| } |
| |
| input[type="range"]::-moz-range-thumb { |
| height: 18px; |
| width: 18px; |
| border-radius: 50%; |
| background: #3a6ea5; |
| cursor: grab; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.2); |
| border: none; |
| } |
| |
| input[type="range"]:active::-moz-range-thumb { |
| cursor: grabbing; |
| background: #2471a3; |
| } |
| |
| .value-display { |
| font-weight: 600; |
| min-width: 60px; |
| text-align: left; |
| font-family: monospace; |
| font-size: 0.95em; |
| background: #f5f7fa; |
| padding: 4px 8px; |
| border-radius: 4px; |
| color: #34495e; |
| box-shadow: inset 0 1px 2px rgba(0,0,0,0.05); |
| } |
| |
| |
| .control-section { |
| grid-column: 1 / -1; |
| margin-top: 10px; |
| margin-bottom: 5px; |
| padding-bottom: 5px; |
| border-bottom: 1px solid #e0e0e0; |
| font-weight: 600; |
| color: #2c3e50; |
| font-size: 1.05em; |
| } |
| |
| .control-section:first-of-type { |
| margin-top: 0; |
| } |
| |
| |
| |
| input[type="range"]:active { |
| cursor: grabbing; |
| } |
| |
| .value-display { |
| font-weight: bold; |
| min-width: 60px; |
| text-align: left; |
| font-family: 'Consolas', monospace; |
| font-size: 0.9em; |
| color: #2c3e50; |
| background: #f5f5f5; |
| padding: 3px 6px; |
| border-radius: 4px; |
| } |
| |
| |
| @media (max-width: 1000px) { |
| body { |
| padding: 15px; |
| } |
| |
| h1 { |
| font-size: 1.8rem; |
| margin-bottom: 15px; |
| } |
| |
| #main-layout { |
| flex-direction: column; |
| align-items: center; |
| height: auto; |
| gap: 20px; |
| } |
| |
| #bike-container { |
| width: 100%; |
| flex-basis: auto; |
| margin-bottom: 10px; |
| align-self: auto; |
| max-height: 60vh; |
| } |
| |
| #controls { |
| width: 100%; |
| max-width: 600px; |
| flex-basis: auto; |
| max-height: none; |
| padding: 20px; |
| gap: 12px 15px; |
| } |
| } |
| |
| |
| @media (max-width: 600px) { |
| body { |
| padding: 10px; |
| } |
| |
| h1 { |
| font-size: 1.5rem; |
| margin-bottom: 12px; |
| } |
| |
| #bike-container { |
| padding: 10px; |
| } |
| |
| #controls { |
| padding: 15px; |
| gap: 10px 10px; |
| } |
| |
| label { |
| font-size: 0.8em; |
| } |
| |
| .value-display { |
| font-size: 0.8em; |
| min-width: 50px; |
| } |
| } |
| |
| |
| </style> |
| </head> |
| <body> |
|
|
| <h1>Interactive Bike Geometry Visualizer</h1> |
| <p class="tool-description">Adjust the sliders to customize your bike's geometry and see changes in real-time.</p> |
|
|
| |
| <div id="main-layout"> |
|
|
| <div id="bike-container"> |
| <svg id="bike-svg" viewBox="-425 -225 2000 1500" preserveAspectRatio="xMidYMid meet"> |
| |
| <g id="bike-elements"> |
| |
| <g id="rear-wheel-group"> |
| <circle id="rear-wheel" class="wheel" r="340" /> |
| <line class="spokes" /> <line class="spokes" /> <line class="spokes" /> <line class="spokes" /> |
| <circle id="rear-axle" class="axle" r="8" /> |
| </g> |
| <g id="front-wheel-group"> |
| <circle id="front-wheel" class="wheel" r="340" /> |
| <line class="spokes" /> <line class="spokes" /> <line class="spokes" /> <line class="spokes" /> |
| <circle id="front-axle" class="axle" r="8" /> |
| </g> |
|
|
| |
| <g id="frame"> |
| <line id="seat-tube" class="frame-tube" /> |
| <line id="top-tube" class="frame-tube" /> |
| <line id="down-tube" class="frame-tube" /> |
| <line id="chainstay" class="frame-tube" /> |
| <line id="seatstay" class="frame-tube" /> |
| </g> |
|
|
| |
| <g id="components"> |
| <line id="fork" class="component" /> |
| <line id="head-tube" class="component" stroke-width="9"/> |
| <line id="headset-spacers" class="headset-spacer" /> |
| <line id="stem" class="component" /> |
| <line id="handlebar" class="component" /> |
| <line id="seatpost" class="component" /> |
| <g id="saddle-group"> |
| <path id="saddle" class="saddle" d="M-35,0 Q0,-25 35,0 L 45,-15 L -45,-15 Z" /> |
| </g> |
| |
| <line id="crank-arm" class="drivetrain" /> |
| <circle id="pedal" class="pedal" r="6" /> |
| </g> |
|
|
| |
| <g id="joints"> |
| <circle id="bottom-bracket" class="joint" r="12" /> |
| <circle id="seat-cluster" class="joint" r="8" /> |
| <circle id="head-tube-top" class="joint" r="8" /> |
| <circle id="head-tube-bottom" class="joint" r="8" /> |
| <circle id="stem-clamp" class="joint" r="5" /> |
| </g> |
| </g> |
| </svg> |
| </div> |
|
|
| <div id="controls"> |
| |
| <div class="control-section">Frame Geometry</div> |
| |
| <label for="wheelRadius">Wheel Radius:</label> |
| <input type="range" id="wheelRadius" min="250" max="400" value="340" step="5"> |
| <span class="value-display" id="wheelRadiusValue">340mm</span> |
|
|
| <label for="seatTubeLength">Seat Tube (C-T):</label> |
| <input type="range" id="seatTubeLength" min="350" max="650" value="520" step="10"> |
| <span class="value-display" id="seatTubeLengthValue">520mm</span> |
|
|
| <label for="effectiveTopTube">Eff. Top Tube:</label> |
| <input type="range" id="effectiveTopTube" min="450" max="650" value="550" step="10"> |
| <span class="value-display" id="effectiveTopTubeValue">550mm</span> |
|
|
| <label for="chainstayLength">Chainstay:</label> |
| <input type="range" id="chainstayLength" min="380" max="480" value="425" step="5"> |
| <span class="value-display" id="chainstayLengthValue">425mm</span> |
|
|
| <label for="headTubeAngle">Head Angle:</label> |
| <input type="range" id="headTubeAngle" min="65" max="78" value="72" step="0.5"> |
| <span class="value-display" id="headTubeAngleValue">72.0°</span> |
|
|
| <label for="seatTubeAngle">Seat Angle:</label> |
| <input type="range" id="seatTubeAngle" min="68" max="78" value="73.5" step="0.5"> |
| <span class="value-display" id="seatTubeAngleValue">73.5°</span> |
|
|
| <label for="bbDrop">BB Drop:</label> |
| <input type="range" id="bbDrop" min="50" max="90" value="70" step="2"> |
| <span class="value-display" id="bbDropValue">70mm</span> |
|
|
| <label for="headTubeLength">Head Tube:</label> |
| <input type="range" id="headTubeLength" min="80" max="220" value="140" step="5"> |
| <span class="value-display" id="headTubeLengthValue">140mm</span> |
|
|
| |
| <div class="control-section">Front End</div> |
| |
| <label for="forkLength">Fork (Axle-Crown):</label> |
| <input type="range" id="forkLength" min="350" max="500" value="400" step="5"> |
| <span class="value-display" id="forkLengthValue">400mm</span> |
|
|
| <label for="forkRake">Fork Rake:</label> |
| <input type="range" id="forkRake" min="30" max="65" value="45" step="1"> |
| <span class="value-display" id="forkRakeValue">45mm</span> |
|
|
| |
| <div class="control-section">Cockpit</div> |
| |
| <label for="stemStackHeight">Stem Stack:</label> |
| <input type="range" id="stemStackHeight" min="0" max="50" value="10" step="2"> |
| <span class="value-display" id="stemStackHeightValue">10mm</span> |
|
|
| <label for="stemLength">Stem Length:</label> |
| <input type="range" id="stemLength" min="50" max="140" value="90" step="5"> |
| <span class="value-display" id="stemLengthValue">90mm</span> |
|
|
| <label for="stemAngle">Stem Angle:</label> |
| <input type="range" id="stemAngle" min="-20" max="30" value="6" step="1"> |
| <span class="value-display" id="stemAngleValue">6°</span> |
|
|
| <label for="handlebarWidth">Handlebar Width:</label> |
| <input type="range" id="handlebarWidth" min="360" max="800" value="420" step="10"> |
| <span class="value-display" id="handlebarWidthValue">420mm</span> |
|
|
| <label for="handlebarRise">Handlebar Rise:</label> |
| <input type="range" id="handlebarRise" min="-30" max="50" value="15" step="5"> |
| <span class="value-display" id="handlebarRiseValue">15mm</span> |
|
|
| |
| <div class="control-section">Saddle & Seatpost</div> |
| |
| <label for="seatpostExposure">Seatpost Exposure:</label> |
| <input type="range" id="seatpostExposure" min="50" max="300" value="150" step="10"> |
| <span class="value-display" id="seatpostExposureValue">150mm</span> |
|
|
| <label for="saddleSetback">Saddle Setback:</label> |
| <input type="range" id="saddleSetback" min="-10" max="40" value="20" step="2"> |
| <span class="value-display" id="saddleSetbackValue">20mm</span> |
|
|
| |
| <div class="control-section">Drivetrain</div> |
| |
| <label for="crankLength">Crank Length:</label> |
| <input type="range" id="crankLength" min="150" max="180" value="172.5" step="2.5"> |
| <span class="value-display" id="crankLengthValue">172.5mm</span> |
| </div> |
|
|
| </div> |
|
|
| |
|
|
| <script> |
| |
| const svg = document.getElementById('bike-svg'); |
| const bikeElements = document.getElementById('bike-elements'); |
| |
| |
| const rearWheel = document.getElementById('rear-wheel'); |
| const frontWheel = document.getElementById('front-wheel'); |
| const rearAxleCircle = document.getElementById('rear-axle'); |
| const frontAxleCircle = document.getElementById('front-axle'); |
| const seatTubeLine = document.getElementById('seat-tube'); |
| const topTubeLine = document.getElementById('top-tube'); |
| const downTubeLine = document.getElementById('down-tube'); |
| const chainstayLine = document.getElementById('chainstay'); |
| const seatstayLine = document.getElementById('seatstay'); |
| const forkLine = document.getElementById('fork'); |
| const headTubeLine = document.getElementById('head-tube'); |
| const headsetSpacersLine = document.getElementById('headset-spacers'); |
| const stemLine = document.getElementById('stem'); |
| const handlebarLine = document.getElementById('handlebar'); |
| const seatpostLine = document.getElementById('seatpost'); |
| const saddleGroup = document.getElementById('saddle-group'); |
| const saddlePath = document.getElementById('saddle'); |
| const crankArmLine = document.getElementById('crank-arm'); |
| const pedalCircle = document.getElementById('pedal'); |
| const bbCircle = document.getElementById('bottom-bracket'); |
| const seatClusterCircle = document.getElementById('seat-cluster'); |
| const headTubeTopCircle = document.getElementById('head-tube-top'); |
| const headTubeBottomCircle = document.getElementById('head-tube-bottom'); |
| const stemClampCircle = document.getElementById('stem-clamp'); |
| |
| const rearWheelGroup = document.getElementById('rear-wheel-group'); |
| const frontWheelGroup = document.getElementById('front-wheel-group'); |
| const rearSpokes = rearWheelGroup.querySelectorAll('.spokes'); |
| const frontSpokes = frontWheelGroup.querySelectorAll('.spokes'); |
| |
| |
| |
| const controls = { |
| wheelRadius: document.getElementById('wheelRadius'), |
| seatTubeLength: document.getElementById('seatTubeLength'), |
| effectiveTopTube: document.getElementById('effectiveTopTube'), |
| chainstayLength: document.getElementById('chainstayLength'), |
| headTubeAngle: document.getElementById('headTubeAngle'), |
| seatTubeAngle: document.getElementById('seatTubeAngle'), |
| bbDrop: document.getElementById('bbDrop'), |
| forkLength: document.getElementById('forkLength'), |
| forkRake: document.getElementById('forkRake'), |
| headTubeLength: document.getElementById('headTubeLength'), |
| stemStackHeight: document.getElementById('stemStackHeight'), |
| stemLength: document.getElementById('stemLength'), |
| stemAngle: document.getElementById('stemAngle'), |
| handlebarWidth: document.getElementById('handlebarWidth'), |
| handlebarRise: document.getElementById('handlebarRise'), |
| seatpostExposure: document.getElementById('seatpostExposure'), |
| saddleSetback: document.getElementById('saddleSetback'), |
| crankLength: document.getElementById('crankLength'), |
| }; |
| |
| |
| const displays = { |
| wheelRadius: document.getElementById('wheelRadiusValue'), |
| seatTubeLength: document.getElementById('seatTubeLengthValue'), |
| effectiveTopTube: document.getElementById('effectiveTopTubeValue'), |
| chainstayLength: document.getElementById('chainstayLengthValue'), |
| headTubeAngle: document.getElementById('headTubeAngleValue'), |
| seatTubeAngle: document.getElementById('seatTubeAngleValue'), |
| bbDrop: document.getElementById('bbDropValue'), |
| forkLength: document.getElementById('forkLengthValue'), |
| forkRake: document.getElementById('forkRakeValue'), |
| headTubeLength: document.getElementById('headTubeLengthValue'), |
| stemStackHeight: document.getElementById('stemStackHeightValue'), |
| stemLength: document.getElementById('stemLengthValue'), |
| stemAngle: document.getElementById('stemAngleValue'), |
| handlebarWidth: document.getElementById('handlebarWidthValue'), |
| handlebarRise: document.getElementById('handlebarRiseValue'), |
| seatpostExposure: document.getElementById('seatpostExposureValue'), |
| saddleSetback: document.getElementById('saddleSetbackValue'), |
| crankLength: document.getElementById('crankLengthValue'), |
| }; |
| |
| |
| const SVG_OFFSET_X = 100; |
| const SVG_OFFSET_Y = 550; |
| const CRANK_ANGLE_DEG = 45; |
| |
| |
| |
| function updateBike() { |
| |
| const params = {}; |
| for (const key in controls) { |
| params[key] = parseFloat(controls[key].value); |
| |
| let displayValue = params[key]; |
| let unit = 'mm'; |
| if (key.includes('Angle')) { |
| unit = '°'; |
| displays[key].textContent = `${displayValue.toFixed(1)}${unit}`; |
| } else if (key === 'crankLength') { |
| displays[key].textContent = `${displayValue.toFixed(1)}${unit}`; |
| } |
| else { |
| displays[key].textContent = `${displayValue}${unit}`; |
| } |
| } |
| |
| |
| const headAngleRad = params.headTubeAngle * Math.PI / 180; |
| const seatAngleRad = params.seatTubeAngle * Math.PI / 180; |
| const stemAngleRad = params.stemAngle * Math.PI / 180; |
| const crankAngleRad = CRANK_ANGLE_DEG * Math.PI / 180; |
| |
| |
| |
| const rearAxle = { x: 0, y: 0 }; |
| const bb = { x: params.chainstayLength, y: params.bbDrop }; |
| |
| const seatCluster = { |
| x: bb.x - params.seatTubeLength * Math.cos(seatAngleRad), |
| y: bb.y - params.seatTubeLength * Math.sin(seatAngleRad) |
| }; |
| |
| |
| const ettPointOnSeatpostLine = { |
| x: seatCluster.x, |
| y: seatCluster.y |
| }; |
| const headTubeTopInitial = { |
| x: ettPointOnSeatpostLine.x + params.effectiveTopTube, |
| y: ettPointOnSeatpostLine.y |
| }; |
| |
| |
| const headTubeBottom = { |
| x: headTubeTopInitial.x + params.headTubeLength * Math.cos(headAngleRad), |
| y: headTubeTopInitial.y + params.headTubeLength * Math.sin(headAngleRad) |
| }; |
| |
| |
| const steererPointAxleLevel = { |
| x: headTubeBottom.x + params.forkLength * Math.cos(headAngleRad), |
| y: headTubeBottom.y + params.forkLength * Math.sin(headAngleRad) |
| }; |
| const rakeAngleRad = headAngleRad + Math.PI / 2; |
| const rakeVector = { |
| x: params.forkRake * Math.cos(rakeAngleRad), |
| y: params.forkRake * Math.sin(rakeAngleRad) |
| }; |
| const frontAxle = { |
| x: steererPointAxleLevel.x + rakeVector.x, |
| y: steererPointAxleLevel.y + rakeVector.y |
| }; |
| |
| |
| |
| const steererVec = { |
| x: -Math.cos(headAngleRad), |
| y: -Math.sin(headAngleRad) |
| }; |
| const stemStartPoint = { |
| x: headTubeTopInitial.x + params.stemStackHeight * steererVec.x, |
| y: headTubeTopInitial.y + params.stemStackHeight * steererVec.y |
| }; |
| |
| const stemEnd = { |
| x: stemStartPoint.x + params.stemLength * Math.cos(stemAngleRad), |
| y: stemStartPoint.y - params.stemLength * Math.sin(stemAngleRad) |
| }; |
| |
| const halfBarWidth = params.handlebarWidth / 2; |
| const handlebarCenter = { |
| x: stemEnd.x, |
| y: stemEnd.y - params.handlebarRise |
| }; |
| const handlebarLeft = { x: handlebarCenter.x - halfBarWidth, y: handlebarCenter.y }; |
| const handlebarRight = { x: handlebarCenter.x + halfBarWidth, y: handlebarCenter.y }; |
| |
| |
| |
| const seatpostTopNominal = { |
| x: seatCluster.x - params.seatpostExposure * Math.cos(seatAngleRad), |
| y: seatCluster.y - params.seatpostExposure * Math.sin(seatAngleRad) |
| }; |
| const seatpostTop = { |
| x: seatpostTopNominal.x - params.saddleSetback, |
| y: seatpostTopNominal.y |
| }; |
| |
| |
| |
| const crankEnd = { |
| x: bb.x + params.crankLength * Math.cos(crankAngleRad), |
| y: bb.y + params.crankLength * Math.sin(crankAngleRad) |
| }; |
| |
| |
| |
| const applyOffset = (point) => ({ x: point.x + SVG_OFFSET_X, y: point.y + SVG_OFFSET_Y }); |
| |
| const svgRearAxle = applyOffset(rearAxle); |
| const svgBB = applyOffset(bb); |
| const svgSeatCluster = applyOffset(seatCluster); |
| const svgHeadTubeTopInitial = applyOffset(headTubeTopInitial); |
| const svgHeadTubeBottom = applyOffset(headTubeBottom); |
| const svgFrontAxle = applyOffset(frontAxle); |
| const svgStemStartPoint = applyOffset(stemStartPoint); |
| const svgStemEnd = applyOffset(stemEnd); |
| const svgHandlebarLeft = applyOffset(handlebarLeft); |
| const svgHandlebarRight = applyOffset(handlebarRight); |
| const svgSeatpostTop = applyOffset(seatpostTop); |
| const svgCrankEnd = applyOffset(crankEnd); |
| |
| |
| |
| |
| rearWheel.setAttribute('cx', svgRearAxle.x); rearWheel.setAttribute('cy', svgRearAxle.y); |
| rearWheel.setAttribute('r', params.wheelRadius); |
| rearAxleCircle.setAttribute('cx', svgRearAxle.x); rearAxleCircle.setAttribute('cy', svgRearAxle.y); |
| |
| frontWheel.setAttribute('cx', svgFrontAxle.x); frontWheel.setAttribute('cy', svgFrontAxle.y); |
| frontWheel.setAttribute('r', params.wheelRadius); |
| frontAxleCircle.setAttribute('cx', svgFrontAxle.x); frontAxleCircle.setAttribute('cy', svgFrontAxle.y); |
| |
| |
| const updateSpokes = (spokes, center, radius) => { |
| spokes[0].setAttribute('x1', center.x - radius); spokes[0].setAttribute('y1', center.y); |
| spokes[0].setAttribute('x2', center.x + radius); spokes[0].setAttribute('y2', center.y); |
| spokes[1].setAttribute('x1', center.x); spokes[1].setAttribute('y1', center.y - radius); |
| spokes[1].setAttribute('x2', center.x); spokes[1].setAttribute('y2', center.y + radius); |
| spokes[2].setAttribute('x1', center.x - radius * 0.707); spokes[2].setAttribute('y1', center.y - radius * 0.707); |
| spokes[2].setAttribute('x2', center.x + radius * 0.707); spokes[2].setAttribute('y2', center.y + radius * 0.707); |
| spokes[3].setAttribute('x1', center.x - radius * 0.707); spokes[3].setAttribute('y1', center.y + radius * 0.707); |
| spokes[3].setAttribute('x2', center.x + radius * 0.707); spokes[3].setAttribute('y2', center.y - radius * 0.707); |
| }; |
| updateSpokes(rearSpokes, svgRearAxle, params.wheelRadius); |
| updateSpokes(frontSpokes, svgFrontAxle, params.wheelRadius); |
| |
| |
| seatTubeLine.setAttribute('x1', svgBB.x); seatTubeLine.setAttribute('y1', svgBB.y); |
| seatTubeLine.setAttribute('x2', svgSeatCluster.x); seatTubeLine.setAttribute('y2', svgSeatCluster.y); |
| |
| topTubeLine.setAttribute('x1', svgSeatCluster.x); topTubeLine.setAttribute('y1', svgSeatCluster.y); |
| topTubeLine.setAttribute('x2', svgHeadTubeTopInitial.x); topTubeLine.setAttribute('y2', svgHeadTubeTopInitial.y); |
| |
| downTubeLine.setAttribute('x1', svgBB.x); downTubeLine.setAttribute('y1', svgBB.y); |
| downTubeLine.setAttribute('x2', svgHeadTubeBottom.x); downTubeLine.setAttribute('y2', svgHeadTubeBottom.y); |
| |
| chainstayLine.setAttribute('x1', svgBB.x); chainstayLine.setAttribute('y1', svgBB.y); |
| chainstayLine.setAttribute('x2', svgRearAxle.x); chainstayLine.setAttribute('y2', svgRearAxle.y); |
| |
| seatstayLine.setAttribute('x1', svgSeatCluster.x); seatstayLine.setAttribute('y1', svgSeatCluster.y); |
| seatstayLine.setAttribute('x2', svgRearAxle.x); seatstayLine.setAttribute('y2', svgRearAxle.y); |
| |
| |
| headTubeLine.setAttribute('x1', svgHeadTubeTopInitial.x); headTubeLine.setAttribute('y1', svgHeadTubeTopInitial.y); |
| headTubeLine.setAttribute('x2', svgHeadTubeBottom.x); headTubeLine.setAttribute('y2', svgHeadTubeBottom.y); |
| |
| forkLine.setAttribute('x1', svgHeadTubeBottom.x); forkLine.setAttribute('y1', svgHeadTubeBottom.y); |
| forkLine.setAttribute('x2', svgFrontAxle.x); forkLine.setAttribute('y2', svgFrontAxle.y); |
| |
| |
| if (params.stemStackHeight > 0) { |
| headsetSpacersLine.setAttribute('x1', svgHeadTubeTopInitial.x); headsetSpacersLine.setAttribute('y1', svgHeadTubeTopInitial.y); |
| headsetSpacersLine.setAttribute('x2', svgStemStartPoint.x); headsetSpacersLine.setAttribute('y2', svgStemStartPoint.y); |
| headsetSpacersLine.style.display = 'inline'; |
| } else { |
| headsetSpacersLine.style.display = 'none'; |
| } |
| |
| stemLine.setAttribute('x1', svgStemStartPoint.x); stemLine.setAttribute('y1', svgStemStartPoint.y); |
| stemLine.setAttribute('x2', svgStemEnd.x); stemLine.setAttribute('y2', svgStemEnd.y); |
| |
| handlebarLine.setAttribute('x1', svgHandlebarLeft.x); handlebarLine.setAttribute('y1', svgHandlebarLeft.y); |
| handlebarLine.setAttribute('x2', svgHandlebarRight.x); handlebarLine.setAttribute('y2', svgHandlebarRight.y); |
| |
| seatpostLine.setAttribute('x1', svgSeatCluster.x); seatpostLine.setAttribute('y1', svgSeatCluster.y); |
| seatpostLine.setAttribute('x2', svgSeatpostTop.x); |
| seatpostLine.setAttribute('y2', svgSeatpostTop.y); |
| |
| |
| const saddleAngleDeg = (seatAngleRad * 180 / Math.PI) - 90; |
| saddleGroup.setAttribute('transform', `translate(${svgSeatpostTop.x}, ${svgSeatpostTop.y}) rotate(${saddleAngleDeg})`); |
| |
| |
| crankArmLine.setAttribute('x1', svgBB.x); crankArmLine.setAttribute('y1', svgBB.y); |
| crankArmLine.setAttribute('x2', svgCrankEnd.x); crankArmLine.setAttribute('y2', svgCrankEnd.y); |
| pedalCircle.setAttribute('cx', svgCrankEnd.x); pedalCircle.setAttribute('cy', svgCrankEnd.y); |
| |
| |
| bbCircle.setAttribute('cx', svgBB.x); bbCircle.setAttribute('cy', svgBB.y); |
| seatClusterCircle.setAttribute('cx', svgSeatCluster.x); seatClusterCircle.setAttribute('cy', svgSeatCluster.y); |
| headTubeTopCircle.setAttribute('cx', svgHeadTubeTopInitial.x); headTubeTopCircle.setAttribute('cy', svgHeadTubeTopInitial.y); |
| headTubeBottomCircle.setAttribute('cx', svgHeadTubeBottom.x); headTubeBottomCircle.setAttribute('cy', svgHeadTubeBottom.y); |
| stemClampCircle.setAttribute('cx', svgStemEnd.x); stemClampCircle.setAttribute('cy', svgStemEnd.y); |
| } |
| |
| |
| for (const key in controls) { |
| controls[key].addEventListener('input', updateBike); |
| } |
| |
| |
| updateBike(); |
| |
| |
| function resetToDefaults() { |
| |
| for (const key in controls) { |
| controls[key].value = controls[key].defaultValue; |
| } |
| |
| updateBike(); |
| } |
| </script> |
| </body> |
| </html> |