Compare commits
13 Commits
nicerRobot
...
PWA
| Author | SHA1 | Date | |
|---|---|---|---|
|
fb8cf9bbfc
|
|||
|
402b147654
|
|||
|
e9d3b71f28
|
|||
|
e593efafa4
|
|||
|
7d02789a39
|
|||
|
b1b67fb0bd
|
|||
|
62bb28ca9d
|
|||
|
07204a476e
|
|||
|
5662142274
|
|||
|
83049815db
|
|||
|
ba710fcf5d
|
|||
|
0bc7417f35
|
|||
|
2684ed2c72
|
28
README.md
Normal file
28
README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# MuneBase Swerve Sim
|
||||||
|
|
||||||
|
A browser-based simulator for Swerve Drive robots.
|
||||||
|
|
||||||
|
Live demo: https://swerve-vis.munebase.dev
|
||||||
|
|
||||||
|
|
||||||
|
You can host the project locally with a simple static server and open it in your browser:
|
||||||
|
|
||||||
|
- Python 3 (built-in):
|
||||||
|
|
||||||
|
python3 -m http.server 8000
|
||||||
|
|
||||||
|
Then open: http://localhost:8000
|
||||||
|
|
||||||
|
- Node.js (with npx):
|
||||||
|
|
||||||
|
npx http-server -c-1 .
|
||||||
|
|
||||||
|
Open `index.html` from the project root in your browser if your editor provides a static preview.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
|
||||||
|
- Third-party scripts are in the `vendor/` directory.
|
||||||
|
|
||||||
|
License
|
||||||
|
|
||||||
|
This repository is provided as-is.
|
||||||
22
icons/192.svg
Normal file
22
icons/192.svg
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="192" height="192" fill="#16213e"/>
|
||||||
|
<circle cx="96" cy="96" r="70" fill="none" stroke="#0f3460" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- Swerve modules (4 corners) -->
|
||||||
|
<circle cx="56" cy="56" r="12" fill="#e94560"/>
|
||||||
|
<circle cx="136" cy="56" r="12" fill="#e94560"/>
|
||||||
|
<circle cx="56" cy="136" r="12" fill="#e94560"/>
|
||||||
|
<circle cx="136" cy="136" r="12" fill="#e94560"/>
|
||||||
|
|
||||||
|
<!-- Velocity arrows -->
|
||||||
|
<line x1="56" y1="56" x2="56" y2="40" stroke="#ff6b6b" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<line x1="136" y1="56" x2="136" y2="40" stroke="#ff6b6b" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<line x1="56" y1="136" x2="56" y2="152" stroke="#ff6b6b" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<line x1="136" y1="136" x2="136" y2="152" stroke="#ff6b6b" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Center point -->
|
||||||
|
<circle cx="96" cy="96" r="6" fill="#4ecca3"/>
|
||||||
|
|
||||||
|
<!-- Robot frame -->
|
||||||
|
<polygon points="56,56 136,56 136,136 56,136" fill="none" stroke="#4ecca3" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
41
icons/512.svg
Normal file
41
icons/512.svg
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="512" height="512" fill="#16213e"/>
|
||||||
|
<circle cx="256" cy="256" r="190" fill="none" stroke="#0f3460" stroke-width="8"/>
|
||||||
|
|
||||||
|
<!-- Swerve modules (4 corners) -->
|
||||||
|
<circle cx="150" cy="150" r="32" fill="#e94560"/>
|
||||||
|
<circle cx="362" cy="150" r="32" fill="#e94560"/>
|
||||||
|
<circle cx="150" cy="362" r="32" fill="#e94560"/>
|
||||||
|
<circle cx="362" cy="362" r="32" fill="#e94560"/>
|
||||||
|
|
||||||
|
<!-- Velocity arrows -->
|
||||||
|
<line x1="150" y1="150" x2="150" y2="100" stroke="#ff6b6b" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<polygon points="150,90 140,110 160,110" fill="#ff6b6b"/>
|
||||||
|
|
||||||
|
<line x1="362" y1="150" x2="362" y2="100" stroke="#ff6b6b" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<polygon points="362,90 352,110 372,110" fill="#ff6b6b"/>
|
||||||
|
|
||||||
|
<line x1="150" y1="362" x2="150" y2="412" stroke="#ff6b6b" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<polygon points="150,422 140,402 160,402" fill="#ff6b6b"/>
|
||||||
|
|
||||||
|
<line x1="362" y1="362" x2="362" y2="412" stroke="#ff6b6b" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<polygon points="362,422 352,402 372,402" fill="#ff6b6b"/>
|
||||||
|
|
||||||
|
<!-- Center point -->
|
||||||
|
<circle cx="256" cy="256" r="16" fill="#4ecca3"/>
|
||||||
|
|
||||||
|
<!-- Robot frame -->
|
||||||
|
<polygon points="150,150 362,150 362,362 150,362" fill="none" stroke="#4ecca3" stroke-width="6"/>
|
||||||
|
|
||||||
|
<!-- Grid lines in background -->
|
||||||
|
<g opacity="0.2" stroke="#4ecca3" stroke-width="1">
|
||||||
|
<line x1="100" y1="100" x2="100" y2="412"/>
|
||||||
|
<line x1="200" y1="100" x2="200" y2="412"/>
|
||||||
|
<line x1="300" y1="100" x2="300" y2="412"/>
|
||||||
|
<line x1="400" y1="100" x2="400" y2="412"/>
|
||||||
|
<line x1="100" y1="100" x2="412" y2="100"/>
|
||||||
|
<line x1="100" y1="200" x2="412" y2="200"/>
|
||||||
|
<line x1="100" y1="300" x2="412" y2="300"/>
|
||||||
|
<line x1="100" y1="400" x2="412" y2="400"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
296
index.html
296
index.html
@ -4,7 +4,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#16213e">
|
||||||
|
<meta name="description"
|
||||||
|
content="Interactive simulation of a swerve drive robot with configurable size, speeds, and number of wheels">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/512.svg">
|
||||||
<title>Swerve Drive Visualizer</title>
|
<title>Swerve Drive Visualizer</title>
|
||||||
|
|
||||||
|
<!-- PWA Manifest -->
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@ -14,6 +22,179 @@
|
|||||||
<p>Interactive simulation of a swerve drive robot with configurable size, speeds, and number of wheels</p>
|
<p>Interactive simulation of a swerve drive robot with configurable size, speeds, and number of wheels</p>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
<section class="documentation">
|
||||||
|
<h2>About This Project</h2>
|
||||||
|
<details>
|
||||||
|
<summary>How To Use</summary>
|
||||||
|
<div class="documentation-content">
|
||||||
|
<h3>Getting Started</h3>
|
||||||
|
<p>This interactive visualizer demonstrates how a swerve drive robot moves based on commanded
|
||||||
|
velocities. Use the controls to experiment with different configurations and movement patterns.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Drive Controls</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Strafe Left/Right:</strong> Controls the robot's velocity in the X direction
|
||||||
|
(field-relative). Positive values move right, negative values move left.</li>
|
||||||
|
<li><strong>Move Forward/Backward:</strong> Controls the robot's velocity in the Y direction
|
||||||
|
(field-relative). Positive values move forward, negative values move backward.</li>
|
||||||
|
<li><strong>Rotation:</strong> Controls the robot's angular velocity (turn rate) in radians per
|
||||||
|
second. Positive values rotate counter-clockwise.</li>
|
||||||
|
<li><strong>Max Module Speed:</strong> Sets the maximum speed limit for any individual swerve
|
||||||
|
module. If calculated speeds exceed this, all modules are scaled proportionally.</li>
|
||||||
|
<li><strong>Reset Controls:</strong> Returns all velocity sliders to zero.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Preset Configurations</h3>
|
||||||
|
<p>Choose from 9 pre-built robot configurations ranging from 2 to 16 wheels. Each preset
|
||||||
|
demonstrates different module arrangements:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>2-Wheel:</strong> Differential drive arrangement</li>
|
||||||
|
<li><strong>3-Wheel Triangle:</strong> Three modules in an equilateral triangle</li>
|
||||||
|
<li><strong>4-Wheel Square:</strong> Classic square configuration</li>
|
||||||
|
<li><strong>4-Wheel Rectangle:</strong> Rectangular configuration for longer robots</li>
|
||||||
|
<li><strong>6-Wheel Hexagon:</strong> Hexagonal arrangement</li>
|
||||||
|
<li><strong>8-Wheel Octagon:</strong> Octagonal arrangement</li>
|
||||||
|
<li><strong>8-Wheel Square:</strong> Double-layered square with inner and outer modules</li>
|
||||||
|
<li><strong>12-Wheel Hexagon:</strong> Double-layered hexagonal arrangement</li>
|
||||||
|
<li><strong>16-Wheel Octagon:</strong> Double-layered octagonal arrangement</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Custom Configurations</h3>
|
||||||
|
<p>Create your own robot configuration:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Enter the desired number of modules (Minimum of 2)</li>
|
||||||
|
<li>Click <strong>Generate Position Inputs</strong> to create input fields</li>
|
||||||
|
<li>For each module, specify:
|
||||||
|
<ul>
|
||||||
|
<li><strong>Module Name:</strong> A label for the module</li>
|
||||||
|
<li><strong>X Position:</strong> Distance from robot center (pixels, positive = right)
|
||||||
|
</li>
|
||||||
|
<li><strong>Y Position:</strong> Distance from robot center (pixels, positive = up)</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Click <strong>Apply Custom Configuration</strong> to see your design</li>
|
||||||
|
<li>Use <strong>Remove Position Inputs</strong> to clear the custom fields. This does not reset
|
||||||
|
the robot, only clears the input box</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Understanding the Visualization</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Robot Frame:</strong> The filled polygon connecting the outer-most module positions
|
||||||
|
</li>
|
||||||
|
<li><strong>Modules:</strong> Circular markers at each wheel position</li>
|
||||||
|
<li><strong>Velocity Arrows:</strong> Red arrows showing the direction and magnitude of each
|
||||||
|
module's velocity</li>
|
||||||
|
<li><strong>Grid:</strong> Moves relative to the robot to show field-relative motion</li>
|
||||||
|
<li><strong>Gyro Heading:</strong> The current rotation angle of the robot in degrees</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Module States Panel</h3>
|
||||||
|
<p>Displays real-time information for each module:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Angle:</strong> The direction the module is pointing (in degrees)</li>
|
||||||
|
<li><strong>Speed:</strong> The velocity of the module (in pixels/second)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Explanation of Swerve Kinematics</summary>
|
||||||
|
<div class="documentation-content">
|
||||||
|
<h3>What is Swerve Drive?</h3>
|
||||||
|
<p>Swerve drive (also called holonomic drive) is a drivetrain design where each wheel module can
|
||||||
|
independently rotate and drive in any direction. This allows the robot to move in any direction
|
||||||
|
while simultaneously rotating, providing exceptional maneuverability.</p>
|
||||||
|
|
||||||
|
<h3>Kinematic Equations</h3>
|
||||||
|
<p>The simulator calculates each module's state using inverse kinematics. Given a desired robot
|
||||||
|
velocity (v<sub>x</sub>, v<sub>y</sub>) and rotation rate (ω), we calculate each module's
|
||||||
|
required velocity.</p>
|
||||||
|
|
||||||
|
<h4>Field-Relative vs Robot-Relative</h4>
|
||||||
|
<p>This simulator uses <strong>field-relative control</strong>, meaning the velocity commands are
|
||||||
|
relative to the field, not the robot's current orientation. The inputs are transformed to
|
||||||
|
robot-relative coordinates using the current gyro heading:</p>
|
||||||
|
<pre>
|
||||||
|
v<sub>robot_x</sub> = v<sub>field_x</sub> × cos(-θ) - v<sub>field_y</sub> × sin(-θ)
|
||||||
|
v<sub>robot_y</sub> = v<sub>field_x</sub> × sin(-θ) + v<sub>field_y</sub> × cos(-θ)
|
||||||
|
</pre>
|
||||||
|
<p>Where θ is the robot's heading angle (gyro reading).</p>
|
||||||
|
|
||||||
|
<h4>Module Velocity Calculation</h4>
|
||||||
|
<p>For each module at position (x<sub>i</sub>, y<sub>i</sub>) relative to the robot's center of
|
||||||
|
rotation:</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Translation component:</strong> The robot's linear velocity (v<sub>robot_x</sub>,
|
||||||
|
v<sub>robot_y</sub>)</li>
|
||||||
|
<li><strong>Rotation component:</strong> Perpendicular to the position vector, with magnitude
|
||||||
|
proportional to distance from center:
|
||||||
|
<pre>
|
||||||
|
v<sub>rot_x</sub> = -y<sub>i</sub> × ω
|
||||||
|
v<sub>rot_y</sub> = x<sub>i</sub> × ω
|
||||||
|
</pre>
|
||||||
|
</li>
|
||||||
|
<li><strong>Combined velocity:</strong> Vector sum of translation and rotation:
|
||||||
|
<pre>
|
||||||
|
v<sub>module_x</sub> = v<sub>robot_x</sub> + v<sub>rot_x</sub>
|
||||||
|
v<sub>module_y</sub> = v<sub>robot_y</sub> + v<sub>rot_y</sub>
|
||||||
|
</pre>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h4>Module Angle and Speed</h4>
|
||||||
|
<p>From the module's velocity vector, we calculate:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Speed:</strong> The magnitude of the velocity vector: √(v<sub>x</sub>² +
|
||||||
|
v<sub>y</sub>²)</li>
|
||||||
|
<li><strong>Angle:</strong> The direction of the velocity vector: arctan2(v<sub>y</sub>,
|
||||||
|
v<sub>x</sub>)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Speed Normalization</h4>
|
||||||
|
<p>If any module's calculated speed exceeds the maximum allowed speed, all module velocities are
|
||||||
|
scaled proportionally. This preserves the movement direction while respecting hardware limits:
|
||||||
|
</p>
|
||||||
|
<pre>
|
||||||
|
scale = max_speed / max(calculated_speeds)
|
||||||
|
if scale < 1:
|
||||||
|
all_module_speeds × scale
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<h3>Gyro Integration</h3>
|
||||||
|
<p>The robot's heading (gyro angle) is continuously updated by integrating the rotation rate:</p>
|
||||||
|
<pre>
|
||||||
|
θ<sub>new</sub> = θ<sub>old</sub> + ω × Δt
|
||||||
|
</pre>
|
||||||
|
<p>Where Δt is the time step. The heading is normalized to stay within the range [-π, π].</p>
|
||||||
|
|
||||||
|
<h3>Real-World Applications</h3>
|
||||||
|
<p>Swerve drive systems are commonly used in:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>FRC (FIRST Robotics Competition):</strong> For competitive robots requiring precise
|
||||||
|
positioning</li>
|
||||||
|
<li><strong>Industrial AGVs:</strong> Automated guided vehicles in warehouses</li>
|
||||||
|
<li><strong>Research Platforms:</strong> Mobile robots requiring omnidirectional movement</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Key Advantages</h3>
|
||||||
|
<ul>
|
||||||
|
<li>True holonomic motion (can move in any direction without rotating)</li>
|
||||||
|
<li>Can translate and rotate simultaneously</li>
|
||||||
|
<li>Excellent maneuverability in constrained spaces</li>
|
||||||
|
<li>No "drift" or unwanted rotation during translation</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Implementation Considerations</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Mechanical Complexity:</strong> Each module requires two motors (drive and steering)
|
||||||
|
</li>
|
||||||
|
<li><strong>Control Complexity:</strong> Requires coordinated control of all modules</li>
|
||||||
|
<li><strong>Sensor Requirements:</strong> Absolute encoders recommended for module angles</li>
|
||||||
|
<li><strong>Cost:</strong> More expensive than traditional drivetrains</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
<section class="visualization-canvas">
|
<section class="visualization-canvas">
|
||||||
<h2>Robot Visualization</h2>
|
<h2>Robot Visualization</h2>
|
||||||
<canvas id="swerve-canvas" width="800" height="800"></canvas>
|
<canvas id="swerve-canvas" width="800" height="800"></canvas>
|
||||||
@ -26,24 +207,57 @@
|
|||||||
<legend>Translation & Rotation</legend>
|
<legend>Translation & Rotation</legend>
|
||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label for="vx-slider">Strafe Left/Right (pixels/s)</label>
|
<button id="control-mode-toggle" type="button">Switch to Keyboard/Joystick Controls</button>
|
||||||
<input type="range" id="vx-slider" min="-300" max="300" step="10" value="0">
|
|
||||||
<output id="vx-value">0</output>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-group">
|
<div id="slider-controls">
|
||||||
<label for="vy-slider">Move Forward/Backward (pixels/s)</label>
|
<div class="control-group">
|
||||||
<input type="range" id="vy-slider" min="-300" max="300" step="10" value="0">
|
<label for="vy-slider">Move Forward/Backward (pixels/s)</label>
|
||||||
<output id="vy-value">0</output>
|
<input type="range" id="vy-slider" min="-300" max="300" step="10" value="0">
|
||||||
|
<output id="vy-value">0</output>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="vx-slider">Strafe Left/Right (pixels/s)</label>
|
||||||
|
<input type="range" id="vx-slider" min="-300" max="300" step="10" value="0">
|
||||||
|
<output id="vx-value">0</output>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label for="omega-slider">Rotation (rad/s)</label>
|
||||||
|
<input type="range" id="omega-slider" min="-3" max="3" step="0.1" value="0">
|
||||||
|
<output id="omega-value">0</output>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="reset-btn" type="button">Reset Controls</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-group">
|
<div id="keyboard-controls" style="display: none;">
|
||||||
<label for="omega-slider">Rotation (rad/s)</label>
|
<div class="control-group">
|
||||||
<input type="range" id="omega-slider" min="-3" max="3" step="0.1" value="0">
|
<label for="keyboard-max-speed">Max Speed (pixels/s)</label>
|
||||||
<output id="omega-value">0</output>
|
<input type="range" id="keyboard-max-speed" min="50" max="300" step="10" value="150">
|
||||||
</div>
|
<output id="keyboard-max-speed-value">150</output>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button id="reset-btn" type="button">Reset Controls</button>
|
<div class="control-group">
|
||||||
|
<label for="keyboard-max-rotation">Max Rotation (rad/s)</label>
|
||||||
|
<input type="range" id="keyboard-max-rotation" min="0.5" max="3" step="0.1" value="1.5">
|
||||||
|
<output id="keyboard-max-rotation-value">1.5</output>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="keyboard-instructions">
|
||||||
|
<h4>Keyboard Controls:</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>W:</strong> Move Forward</li>
|
||||||
|
<li><strong>A:</strong> Strafe Left</li>
|
||||||
|
<li><strong>S:</strong> Move Backward</li>
|
||||||
|
<li><strong>D:</strong> Strafe Right</li>
|
||||||
|
<li><strong>Q:</strong> Rotate Counter-Clockwise</li>
|
||||||
|
<li><strong>E:</strong> Rotate Clockwise</li>
|
||||||
|
</ul>
|
||||||
|
<p><em>Click on the canvas area to focus for keyboard input</em></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@ -51,7 +265,7 @@
|
|||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label for="max-speed-slider">Max Module Speed (pixels/s)</label>
|
<label for="max-speed-slider">Max Module Speed (pixels/s)</label>
|
||||||
<input type="range" id="max-speed-slider" min="1" max="300" step="10" value="150">
|
<input type="range" id="max-speed-slider" min="200" max="1000" step="10" value="400">
|
||||||
<output id="max-speed-value">0</output>
|
<output id="max-speed-value">0</output>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -67,6 +281,9 @@
|
|||||||
<button id="preset-4rect" type="button">4-Wheel Rectangle</button>
|
<button id="preset-4rect" type="button">4-Wheel Rectangle</button>
|
||||||
<button id="preset-6wheel" type="button">6-Wheel Hexagon</button>
|
<button id="preset-6wheel" type="button">6-Wheel Hexagon</button>
|
||||||
<button id="preset-8wheel" type="button">8-Wheel Octagon</button>
|
<button id="preset-8wheel" type="button">8-Wheel Octagon</button>
|
||||||
|
<button id="preset-8square" type="button">8-Wheel Square</button>
|
||||||
|
<button id="preset-12hex" type="button">12-Wheel Hexagon</button>
|
||||||
|
<button id="preset-16oct" type="button">16-Wheel Octogon</button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@ -74,7 +291,7 @@
|
|||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label for="module-count">Number of Modules</label>
|
<label for="module-count">Number of Modules</label>
|
||||||
<input type="number" id="module-count" min="2" max="12" value="4" step="1">
|
<input type="number" id="module-count" min="2" max="64" value="4" step="1">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="generate-inputs-btn" type="button">Generate Position Inputs</button>
|
<button id="generate-inputs-btn" type="button">Generate Position Inputs</button>
|
||||||
@ -99,19 +316,50 @@
|
|||||||
<!-- Dynamically generated module data will appear here -->
|
<!-- Dynamically generated module data will appear here -->
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="documentation">
|
|
||||||
<h2>About This Project</h2>
|
|
||||||
<details>
|
|
||||||
<summary>How To Use</summary>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary>Explaination of Swerve Kinematics</summary>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script type="module" src="vendor/lucio/graham-scan.mjs"></script>
|
<script type="module" src="vendor/lucio/graham-scan.mjs"></script>
|
||||||
<script type="module" src="script.js"></script>
|
<script type="module" src="script.js"></script>
|
||||||
|
|
||||||
|
<!-- Register Service Worker for PWA -->
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then((registration) => {
|
||||||
|
console.log('Service Worker registered successfully:', registration.scope);
|
||||||
|
|
||||||
|
// Check for updates periodically
|
||||||
|
setInterval(() => {
|
||||||
|
registration.update();
|
||||||
|
}, 60000); // Check every minute
|
||||||
|
|
||||||
|
// Handle updates
|
||||||
|
registration.addEventListener('updatefound', () => {
|
||||||
|
const newWorker = registration.installing;
|
||||||
|
newWorker.addEventListener('statechange', () => {
|
||||||
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
// New service worker available, prompt user to reload
|
||||||
|
if (confirm('A new version is available! Reload to update?')) {
|
||||||
|
newWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log('Service Worker registration failed:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle controller change (new service worker activated)
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
22
manifest.json
Normal file
22
manifest.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "MuneBase Swerve Drive Sim",
|
||||||
|
"short_name": "Swerve Sim",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#16213e",
|
||||||
|
"background_color": "#16213e",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/192.svg",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/512.svg",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
589
script.js
589
script.js
@ -4,6 +4,81 @@
|
|||||||
|
|
||||||
import GrahamScan from "./vendor/lucio/graham-scan.mjs";
|
import GrahamScan from "./vendor/lucio/graham-scan.mjs";
|
||||||
|
|
||||||
|
class Joystick {
|
||||||
|
constructor(ctx, x, y, radius) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.radius = radius;
|
||||||
|
this.nubX = x;
|
||||||
|
this.nubY = y;
|
||||||
|
this.active = false;
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
if (!this.visible)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.ctx.save();
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
||||||
|
this.ctx.fillStyle = rootStyles.getPropertyValue('--primary-dark-blue');
|
||||||
|
this.ctx.fill();
|
||||||
|
|
||||||
|
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(this.nubX, this.nubY, this.radius / 3, 0, Math.PI * 2);
|
||||||
|
this.ctx.fillStyle = rootStyles.getPropertyValue('--accent-blue');
|
||||||
|
this.ctx.fill();
|
||||||
|
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkShouldActivate(x, y) {
|
||||||
|
if (!this.touchInRange(x, y))
|
||||||
|
return;
|
||||||
|
this.active = true;
|
||||||
|
this.processTouch(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
this.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
touchInRange(x, y) {
|
||||||
|
const deltaX = x - this.x;
|
||||||
|
const deltaY = y - this.y;
|
||||||
|
const dist = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
return dist <= this.radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
processTouch(x, y) {
|
||||||
|
if (this.touchInRange(x, y) && this.active) {
|
||||||
|
this.nubX = x;
|
||||||
|
this.nubY = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.nubX = this.x;
|
||||||
|
this.nubY = this.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
getX() {
|
||||||
|
return (this.nubX - this.x) / this.radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
getY() {
|
||||||
|
return (this.y - this.nubY) / this.radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsVisible(visible) {
|
||||||
|
this.visible = visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2D vector class to make some of the math easier
|
// 2D vector class to make some of the math easier
|
||||||
class Vec2D {
|
class Vec2D {
|
||||||
constructor(x, y) {
|
constructor(x, y) {
|
||||||
@ -86,18 +161,19 @@ class SwerveDrive {
|
|||||||
}
|
}
|
||||||
|
|
||||||
drive(velocityX, velocityY, turnSpeed, maxModuleSpeed, deltaTime = 0.01) {
|
drive(velocityX, velocityY, turnSpeed, maxModuleSpeed, deltaTime = 0.01) {
|
||||||
// Update gyro heading first
|
// Store the requested turn speed for later calculation of actual turn speed
|
||||||
this.updateHeading(turnSpeed, deltaTime);
|
this.requestedTurnSpeed = turnSpeed;
|
||||||
|
|
||||||
// Take in a requested speeds and update every module
|
// Take in a requested speeds and update every module (but don't update heading yet)
|
||||||
this.modules.forEach(module =>
|
this.modules.forEach(module =>
|
||||||
module.calculateState(velocityX, velocityY, turnSpeed, this.gyroHeading)
|
module.calculateState(velocityX, velocityY, turnSpeed, this.gyroHeading)
|
||||||
);
|
);
|
||||||
|
|
||||||
// If any speeds exceed the max speed, normalize down so we don't effect movement direction
|
// If any speeds exceed the max speed, normalize down so we don't effect movement direction
|
||||||
const maxCalculated = Math.max(...this.modules.map(m => m.speed), 0);
|
const maxCalculated = Math.max(...this.modules.map(m => m.speed), 0);
|
||||||
|
let scale = 1.0;
|
||||||
if (maxCalculated > maxModuleSpeed) {
|
if (maxCalculated > maxModuleSpeed) {
|
||||||
const scale = maxModuleSpeed / maxCalculated;
|
scale = maxModuleSpeed / maxCalculated;
|
||||||
this.modules.forEach(module => {
|
this.modules.forEach(module => {
|
||||||
module.velocity.x *= scale;
|
module.velocity.x *= scale;
|
||||||
module.velocity.y *= scale;
|
module.velocity.y *= scale;
|
||||||
@ -105,6 +181,38 @@ class SwerveDrive {
|
|||||||
module.angle = module.velocity.angle();
|
module.angle = module.velocity.angle();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update heading with the actual turn speed (scaled if modules were limited)
|
||||||
|
const actualTurnSpeed = turnSpeed * scale;
|
||||||
|
this.updateHeading(actualTurnSpeed, deltaTime);
|
||||||
|
this.actualTurnSpeed = actualTurnSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActualVelocity() {
|
||||||
|
// Calculate the actual robot velocity from the average of module velocities
|
||||||
|
// This returns the velocity in robot-relative coordinates
|
||||||
|
if (this.modules.length === 0) return new Vec2D(0, 0);
|
||||||
|
|
||||||
|
let sumX = 0;
|
||||||
|
let sumY = 0;
|
||||||
|
|
||||||
|
// Average the module velocities (they're in robot frame)
|
||||||
|
this.modules.forEach(module => {
|
||||||
|
sumX += module.velocity.x;
|
||||||
|
sumY += module.velocity.y;
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgX = sumX / this.modules.length;
|
||||||
|
const avgY = sumY / this.modules.length;
|
||||||
|
|
||||||
|
// Transform back to field-relative coordinates
|
||||||
|
const cosHeading = Math.cos(this.gyroHeading);
|
||||||
|
const sinHeading = Math.sin(this.gyroHeading);
|
||||||
|
|
||||||
|
const fieldVelX = avgX * cosHeading - avgY * sinHeading;
|
||||||
|
const fieldVelY = avgX * sinHeading + avgY * cosHeading;
|
||||||
|
|
||||||
|
return new Vec2D(fieldVelX, fieldVelY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,7 +267,7 @@ const PresetConfigs = {
|
|||||||
return modules;
|
return modules;
|
||||||
},
|
},
|
||||||
|
|
||||||
eightWheel: (size) => {
|
eightWheelOctogon: (size) => {
|
||||||
const radius = size / 2;
|
const radius = size / 2;
|
||||||
const modules = [];
|
const modules = [];
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
@ -171,7 +279,64 @@ const PresetConfigs = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return modules;
|
return modules;
|
||||||
}
|
},
|
||||||
|
|
||||||
|
eightWheelSquare: (size) => {
|
||||||
|
const full = size;
|
||||||
|
const half = size / 2;
|
||||||
|
return [
|
||||||
|
{ x: full, y: full, name: "Outer FL" },
|
||||||
|
{ x: full, y: -full, name: "Outer FR" },
|
||||||
|
{ x: -full, y: full, name: "Outer BL" },
|
||||||
|
{ x: -full, y: -full, name: "Outer BR" },
|
||||||
|
{ x: half, y: half, name: "Inner FL" },
|
||||||
|
{ x: half, y: -half, name: "Inner FR" },
|
||||||
|
{ x: -half, y: half, name: "Inner BL" },
|
||||||
|
{ x: -half, y: -half, name: "Inner BR" }
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
twelveWheelHexagon: (size) => {
|
||||||
|
const outerRadius = size;
|
||||||
|
const innerRadius = size / 2;
|
||||||
|
const modules = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const angle = (Math.PI / 2) + (i * Math.PI / 3);
|
||||||
|
modules.push({
|
||||||
|
x: outerRadius * Math.cos(angle),
|
||||||
|
y: outerRadius * Math.sin(angle),
|
||||||
|
name: `Module ${i + 1}`
|
||||||
|
});
|
||||||
|
|
||||||
|
modules.push({
|
||||||
|
x: innerRadius * Math.cos(angle),
|
||||||
|
y: innerRadius * Math.sin(angle),
|
||||||
|
name: `Module ${i + 7}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return modules;
|
||||||
|
},
|
||||||
|
|
||||||
|
sixteenWheelOctogon: (size) => {
|
||||||
|
const outerRadius = size;
|
||||||
|
const innerRadius = size / 2;
|
||||||
|
const modules = [];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const angle = (Math.PI / 2) + (i * Math.PI / 4);
|
||||||
|
modules.push({
|
||||||
|
x: outerRadius * Math.cos(angle),
|
||||||
|
y: outerRadius * Math.sin(angle),
|
||||||
|
name: `Module ${i + 1}`
|
||||||
|
});
|
||||||
|
|
||||||
|
modules.push({
|
||||||
|
x: innerRadius * Math.cos(angle),
|
||||||
|
y: innerRadius * Math.sin(angle),
|
||||||
|
name: `Module ${i + 9}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return modules;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -179,6 +344,15 @@ const PresetConfigs = {
|
|||||||
* BEGIN DOM VARIABLES
|
* BEGIN DOM VARIABLES
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Get canvas
|
||||||
|
const canvas = document.getElementById('swerve-canvas');
|
||||||
|
|
||||||
|
// Get the canvas context as constant
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Get CSS variables for use in canvas
|
||||||
|
const rootStyles = getComputedStyle(document.documentElement);
|
||||||
|
|
||||||
// Get all control elements
|
// Get all control elements
|
||||||
const vxSlider = document.getElementById('vx-slider');
|
const vxSlider = document.getElementById('vx-slider');
|
||||||
const vySlider = document.getElementById('vy-slider');
|
const vySlider = document.getElementById('vy-slider');
|
||||||
@ -198,6 +372,15 @@ const generateInputsBtn = document.getElementById('generate-inputs-btn');
|
|||||||
const clearInputsBtn = document.getElementById('delete-inputs-btn');
|
const clearInputsBtn = document.getElementById('delete-inputs-btn');
|
||||||
const applyCustomBtn = document.getElementById('apply-custom-btn');
|
const applyCustomBtn = document.getElementById('apply-custom-btn');
|
||||||
|
|
||||||
|
// Get control mode elements
|
||||||
|
const controlModeToggle = document.getElementById('control-mode-toggle');
|
||||||
|
const sliderControls = document.getElementById('slider-controls');
|
||||||
|
const keyboardControls = document.getElementById('keyboard-controls');
|
||||||
|
const keyboardMaxSpeed = document.getElementById('keyboard-max-speed');
|
||||||
|
const keyboardMaxSpeedOutput = document.getElementById('keyboard-max-speed-value');
|
||||||
|
const keyboardMaxRotation = document.getElementById('keyboard-max-rotation');
|
||||||
|
const keyboardMaxRotationOutput = document.getElementById('keyboard-max-rotation-value');
|
||||||
|
|
||||||
// Preset buttons
|
// Preset buttons
|
||||||
const preset2WheelBtn = document.getElementById('preset-2wheel');
|
const preset2WheelBtn = document.getElementById('preset-2wheel');
|
||||||
const preset3WheelBtn = document.getElementById('preset-3wheel');
|
const preset3WheelBtn = document.getElementById('preset-3wheel');
|
||||||
@ -205,32 +388,42 @@ const preset4WheelBtn = document.getElementById('preset-4wheel');
|
|||||||
const preset4RectBtn = document.getElementById('preset-4rect');
|
const preset4RectBtn = document.getElementById('preset-4rect');
|
||||||
const preset6WheelBtn = document.getElementById('preset-6wheel');
|
const preset6WheelBtn = document.getElementById('preset-6wheel');
|
||||||
const preset8WheelBtn = document.getElementById('preset-8wheel');
|
const preset8WheelBtn = document.getElementById('preset-8wheel');
|
||||||
|
const preset8SquareBtn = document.getElementById('preset-8square');
|
||||||
|
const preset12HexBtn = document.getElementById('preset-12hex');
|
||||||
|
const preset16OctBtn = document.getElementById('preset-16oct');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* END DOM VARIABLES
|
* END DOM VARIABLES
|
||||||
|
* BEGIN CONTROL MODE AND INPUT DEVICE VARIABLES
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Control mode state
|
||||||
|
let isManualInputMode = false; // true = keyboard/gamepad mode, false = slider mode
|
||||||
|
|
||||||
|
// Keyboard state tracking
|
||||||
|
const keyState = {
|
||||||
|
w: false, // Forward
|
||||||
|
a: false, // Left
|
||||||
|
s: false, // Backward
|
||||||
|
d: false, // Right
|
||||||
|
q: false, // Counter-clockwise
|
||||||
|
e: false // Clockwise
|
||||||
|
};
|
||||||
|
|
||||||
|
// Current manual input velocities (from keyboard or gamepad)
|
||||||
|
let manualInputVelX = 0;
|
||||||
|
let manualInputVelY = 0;
|
||||||
|
let manualInputOmega = 0;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* END CONTROL MODE AND INPUT DEVICE VARIABLES
|
||||||
* BEGIN LISTENER CODE
|
* BEGIN LISTENER CODE
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
vxSlider.addEventListener('input', (e) => {
|
|
||||||
vxOutput.textContent = parseFloat(e.target.value);
|
|
||||||
});
|
|
||||||
vxOutput.textContent = parseFloat(vxSlider.value);
|
|
||||||
|
|
||||||
vySlider.addEventListener('input', (e) => {
|
|
||||||
vyOutput.textContent = parseFloat(e.target.value);
|
|
||||||
});
|
|
||||||
vyOutput.textContent = parseFloat(vySlider.value);
|
|
||||||
|
|
||||||
omegaSlider.addEventListener('input', (e) => {
|
|
||||||
omegaOutput.textContent = parseFloat(e.target.value);
|
|
||||||
});
|
|
||||||
omegaOutput.textContent = parseFloat(omegaSlider.value);
|
|
||||||
|
|
||||||
maxSpeedSlider.addEventListener('input', (e) => {
|
maxSpeedSlider.addEventListener('input', (e) => {
|
||||||
maxSpeedOutput.textContent = parseFloat(e.target.value);
|
maxSpeedOutput.textContent = e.target.value;
|
||||||
});
|
});
|
||||||
maxSpeedOutput.textContent = parseFloat(maxSpeedSlider.value);
|
maxSpeedOutput.textContent = maxSpeedSlider.value;
|
||||||
|
|
||||||
resetBtn.addEventListener('click', (e) => {
|
resetBtn.addEventListener('click', (e) => {
|
||||||
vxSlider.value = 0;
|
vxSlider.value = 0;
|
||||||
@ -242,6 +435,97 @@ resetBtn.addEventListener('click', (e) => {
|
|||||||
omegaOutput.textContent = parseFloat(omegaSlider.value);
|
omegaOutput.textContent = parseFloat(omegaSlider.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keyboard control sliders
|
||||||
|
keyboardMaxSpeed.addEventListener('input', (e) => {
|
||||||
|
keyboardMaxSpeedOutput.textContent = parseFloat(e.target.value);
|
||||||
|
});
|
||||||
|
keyboardMaxSpeedOutput.textContent = parseFloat(keyboardMaxSpeed.value);
|
||||||
|
|
||||||
|
keyboardMaxRotation.addEventListener('input', (e) => {
|
||||||
|
keyboardMaxRotationOutput.textContent = parseFloat(e.target.value);
|
||||||
|
});
|
||||||
|
keyboardMaxRotationOutput.textContent = parseFloat(keyboardMaxRotation.value);
|
||||||
|
|
||||||
|
// Control mode toggle
|
||||||
|
controlModeToggle.addEventListener('click', () => {
|
||||||
|
isManualInputMode = !isManualInputMode;
|
||||||
|
|
||||||
|
if (isManualInputMode) {
|
||||||
|
// Switch to manual input mode (keyboard/gamepad)
|
||||||
|
sliderControls.style.display = 'none';
|
||||||
|
keyboardControls.style.display = 'block';
|
||||||
|
controlModeToggle.textContent = 'Switch to Slider Controls';
|
||||||
|
|
||||||
|
// Reset slider values when switching to manual input
|
||||||
|
vxSlider.value = 0;
|
||||||
|
vySlider.value = 0;
|
||||||
|
omegaSlider.value = 0;
|
||||||
|
vxOutput.textContent = '0';
|
||||||
|
vyOutput.textContent = '0';
|
||||||
|
omegaOutput.textContent = '0';
|
||||||
|
leftJoystick.setIsVisible(true);
|
||||||
|
rightJoystick.setIsVisible(true);
|
||||||
|
} else {
|
||||||
|
// Switch to slider mode
|
||||||
|
sliderControls.style.display = 'block';
|
||||||
|
keyboardControls.style.display = 'none';
|
||||||
|
controlModeToggle.textContent = 'Switch to Keyboard/Joystick Controls';
|
||||||
|
|
||||||
|
// Reset manual input state
|
||||||
|
Object.keys(keyState).forEach(key => keyState[key] = false);
|
||||||
|
manualInputVelX = 0;
|
||||||
|
manualInputVelY = 0;
|
||||||
|
manualInputOmega = 0;
|
||||||
|
leftJoystick.setIsVisible(false);
|
||||||
|
rightJoystick.setIsVisible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard event listeners
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (!isManualInputMode) return;
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (key in keyState) {
|
||||||
|
keyState[key] = true;
|
||||||
|
e.preventDefault();
|
||||||
|
updateManualInputVelocities();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keyup', (e) => {
|
||||||
|
if (!isManualInputMode) return;
|
||||||
|
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (key in keyState) {
|
||||||
|
keyState[key] = false;
|
||||||
|
e.preventDefault();
|
||||||
|
updateManualInputVelocities();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to update velocities based on manual input devices (keyboard/gamepad)
|
||||||
|
function updateManualInputVelocities() {
|
||||||
|
const maxSpeed = parseFloat(keyboardMaxSpeed.value);
|
||||||
|
const maxRotation = parseFloat(keyboardMaxRotation.value);
|
||||||
|
|
||||||
|
// Calculate translation velocities from keyboard input
|
||||||
|
manualInputVelX = 0;
|
||||||
|
manualInputVelY = 0;
|
||||||
|
|
||||||
|
if (keyState.d) manualInputVelX += maxSpeed; // Right
|
||||||
|
if (keyState.a) manualInputVelX -= maxSpeed; // Left
|
||||||
|
if (keyState.w) manualInputVelY += maxSpeed; // Forward
|
||||||
|
if (keyState.s) manualInputVelY -= maxSpeed; // Backward
|
||||||
|
|
||||||
|
// Calculate rotation velocity from keyboard input
|
||||||
|
manualInputOmega = 0;
|
||||||
|
if (keyState.e) manualInputOmega += maxRotation; // Clockwise
|
||||||
|
if (keyState.q) manualInputOmega -= maxRotation; // Counter-clockwise
|
||||||
|
|
||||||
|
// TODO: Add gamepad input processing here
|
||||||
|
}
|
||||||
|
|
||||||
// Preset button event listeners
|
// Preset button event listeners
|
||||||
preset2WheelBtn.addEventListener('click', () => {
|
preset2WheelBtn.addEventListener('click', () => {
|
||||||
const positions = PresetConfigs.twoWheel(robotSize);
|
const positions = PresetConfigs.twoWheel(robotSize);
|
||||||
@ -284,18 +568,44 @@ preset6WheelBtn.addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
preset8WheelBtn.addEventListener('click', () => {
|
preset8WheelBtn.addEventListener('click', () => {
|
||||||
const positions = PresetConfigs.eightWheel(robotSize);
|
const positions = PresetConfigs.eightWheelOctogon(robotSize);
|
||||||
robot.setModules(positions);
|
robot.setModules(positions);
|
||||||
robot.setName("8-Wheel Octogon");
|
robot.setName("8-Wheel Octogon");
|
||||||
createModuleDisplays(robot);
|
createModuleDisplays(robot);
|
||||||
updateModuleDisplays(robot);
|
updateModuleDisplays(robot);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
preset8SquareBtn.addEventListener('click', () => {
|
||||||
|
const positions = PresetConfigs.eightWheelSquare(robotSize);
|
||||||
|
robot.setModules(positions);
|
||||||
|
robot.setName("8-Wheel Square");
|
||||||
|
createModuleDisplays(robot);
|
||||||
|
updateModuleDisplays(robot);
|
||||||
|
});
|
||||||
|
|
||||||
|
preset12HexBtn.addEventListener('click', () => {
|
||||||
|
const positions = PresetConfigs.twelveWheelHexagon(robotSize);
|
||||||
|
robot.setModules(positions);
|
||||||
|
robot.setName("12-Wheel Hexagon");
|
||||||
|
createModuleDisplays(robot);
|
||||||
|
updateModuleDisplays(robot);
|
||||||
|
});
|
||||||
|
|
||||||
|
preset16OctBtn.addEventListener('click', () => {
|
||||||
|
const positions = PresetConfigs.sixteenWheelOctogon(robotSize);
|
||||||
|
robot.setModules(positions);
|
||||||
|
robot.setName("16-Wheel Octogon");
|
||||||
|
createModuleDisplays(robot);
|
||||||
|
updateModuleDisplays(robot);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
generateInputsBtn.addEventListener('click', () => {
|
generateInputsBtn.addEventListener('click', () => {
|
||||||
const count = parseInt(moduleCountInput.value);
|
const count = parseInt(moduleCountInput.value);
|
||||||
|
|
||||||
if (isNaN(count) || count < 2) {
|
if (isNaN(count) || count < 2 || count > 64) {
|
||||||
alert('Please enter a valid number of modules above or equal to 2.');
|
alert('Please enter a valid number of modules between 2 and 64.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
generateModuleInputs(count);
|
generateModuleInputs(count);
|
||||||
@ -330,16 +640,171 @@ applyCustomBtn.addEventListener('click', () => {
|
|||||||
updateModuleDisplays(robot);
|
updateModuleDisplays(robot);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function convertTouchToCanvas(inCoords) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Calculate the scale factor between display size and canvas internal size
|
||||||
|
const scaleX = canvas.width / rect.width;
|
||||||
|
const scaleY = canvas.height / rect.height;
|
||||||
|
|
||||||
|
// Convert client coordinates to canvas coordinates, accounting for scaling
|
||||||
|
const x = (inCoords.x - rect.left) * scaleX - canvas.width / 2;
|
||||||
|
const y = (inCoords.y - rect.top) * scaleY - canvas.height / 2;
|
||||||
|
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', (event) => {
|
||||||
|
const canvasCoords = convertTouchToCanvas({ x: event.clientX, y: event.clientY });
|
||||||
|
leftJoystick.checkShouldActivate(canvasCoords.x, canvasCoords.y);
|
||||||
|
rightJoystick.checkShouldActivate(canvasCoords.x, canvasCoords.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('mousemove', (event) => {
|
||||||
|
const canvasCoords = convertTouchToCanvas({ x: event.clientX, y: event.clientY });
|
||||||
|
leftJoystick.processTouch(canvasCoords.x, canvasCoords.y);
|
||||||
|
rightJoystick.processTouch(canvasCoords.x, canvasCoords.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('mouseup', (event) => {
|
||||||
|
leftJoystick.deactivate();
|
||||||
|
rightJoystick.deactivate();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch event listeners for mobile/tablet support
|
||||||
|
canvas.addEventListener('touchstart', (event) => {
|
||||||
|
event.preventDefault(); // Prevent scrolling and default touch behavior
|
||||||
|
|
||||||
|
for (let i = 0; i < event.touches.length; i++) {
|
||||||
|
const touch = event.touches[i];
|
||||||
|
const canvasCoords = convertTouchToCanvas({ x: touch.clientX, y: touch.clientY });
|
||||||
|
// alert(`X: ${canvasCoords.x}, Y: ${canvasCoords.y}`);
|
||||||
|
leftJoystick.checkShouldActivate(canvasCoords.x, canvasCoords.y);
|
||||||
|
rightJoystick.checkShouldActivate(canvasCoords.x, canvasCoords.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('touchmove', (event) => {
|
||||||
|
event.preventDefault(); // Prevent scrolling while using joysticks
|
||||||
|
|
||||||
|
for (let i = 0; i < event.touches.length; i++) {
|
||||||
|
const touch = event.touches[i];
|
||||||
|
const canvasCoords = convertTouchToCanvas({ x: touch.clientX, y: touch.clientY });
|
||||||
|
leftJoystick.processTouch(canvasCoords.x, canvasCoords.y);
|
||||||
|
rightJoystick.processTouch(canvasCoords.x, canvasCoords.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('touchend', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// If no touches remain, deactivate both joysticks
|
||||||
|
if (event.touches.length === 0) {
|
||||||
|
leftJoystick.deactivate();
|
||||||
|
rightJoystick.deactivate();
|
||||||
|
} else {
|
||||||
|
// Check if the remaining touches are still in range of the joysticks
|
||||||
|
let leftStillActive = false;
|
||||||
|
let rightStillActive = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < event.touches.length; i++) {
|
||||||
|
const touch = event.touches[i];
|
||||||
|
const canvasCoords = convertTouchToCanvas({ x: touch.clientX, y: touch.clientY });
|
||||||
|
|
||||||
|
if (leftJoystick.touchInRange(canvasCoords.x, canvasCoords.y)) {
|
||||||
|
leftStillActive = true;
|
||||||
|
}
|
||||||
|
if (rightJoystick.touchInRange(canvasCoords.x, canvasCoords.y)) {
|
||||||
|
rightStillActive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!leftStillActive) {
|
||||||
|
leftJoystick.deactivate();
|
||||||
|
leftJoystick.reset();
|
||||||
|
}
|
||||||
|
if (!rightStillActive) {
|
||||||
|
rightJoystick.deactivate();
|
||||||
|
rightJoystick.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('touchcancel', (event) => {
|
||||||
|
// Handle touch cancellation (e.g., when system interrupts)
|
||||||
|
leftJoystick.deactivate();
|
||||||
|
leftJoystick.reset();
|
||||||
|
rightJoystick.deactivate();
|
||||||
|
rightJoystick.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* END LISTENER CODE
|
* END LISTENER CODE
|
||||||
* BEGIN DYNAMIC DOM FUNCTIONS
|
* BEGIN DYNAMIC DOM FUNCTIONS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Function to calculate evenly spaced module positions in circular layers
|
||||||
|
function calculateModulePositions(count) {
|
||||||
|
if (count <= 0) return [];
|
||||||
|
|
||||||
|
const baseRadius = 50; // Base radius for the first layer
|
||||||
|
const positions = [];
|
||||||
|
let remainingModules = count;
|
||||||
|
let numberOfLayers = Math.ceil(count / 6);
|
||||||
|
let modulesPerLayer = Math.ceil(count / numberOfLayers);
|
||||||
|
let angleOffset = 0;
|
||||||
|
let currentLayer = 0;
|
||||||
|
let moduleIndex = 0;
|
||||||
|
|
||||||
|
while (remainingModules > 0) {
|
||||||
|
// Determine modules for this layer
|
||||||
|
let modulesInThisLayer;
|
||||||
|
|
||||||
|
if (remainingModules <= modulesPerLayer) {
|
||||||
|
// Last layer gets all remaining modules
|
||||||
|
modulesInThisLayer = remainingModules;
|
||||||
|
} else {
|
||||||
|
// All other layers: try to distribute evenly with max modulesPerLayer
|
||||||
|
const remainingLayers = Math.ceil(remainingModules / modulesPerLayer);
|
||||||
|
modulesInThisLayer = Math.min(modulesPerLayer, Math.ceil(remainingModules / remainingLayers));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate radius for this layer
|
||||||
|
const radius = currentLayer === 0 ? baseRadius : baseRadius * (1 + currentLayer * 0.75);
|
||||||
|
|
||||||
|
// Calculate positions for modules in this layer
|
||||||
|
for (let i = 0; i < modulesInThisLayer; i++) {
|
||||||
|
const angle = (angleOffset + 2 * Math.PI * i) / modulesInThisLayer;
|
||||||
|
const x = Math.round(radius * Math.cos(angle));
|
||||||
|
const y = Math.round(radius * Math.sin(angle));
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
name: `Layer ${currentLayer + 1} Module ${(moduleIndex % modulesPerLayer) + 1}`
|
||||||
|
});
|
||||||
|
|
||||||
|
moduleIndex++;
|
||||||
|
}
|
||||||
|
angleOffset += Math.PI;
|
||||||
|
|
||||||
|
remainingModules -= modulesInThisLayer;
|
||||||
|
currentLayer++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
function generateModuleInputs(count) {
|
function generateModuleInputs(count) {
|
||||||
const container = document.getElementById('module-position-inputs');
|
const container = document.getElementById('module-position-inputs');
|
||||||
container.innerHTML = ''; // Clear existing inputs
|
container.innerHTML = ''; // Clear existing inputs
|
||||||
|
|
||||||
|
// Calculate evenly spaced positions
|
||||||
|
const positions = calculateModulePositions(count);
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
|
const position = positions[i];
|
||||||
const moduleFieldset = document.createElement('fieldset');
|
const moduleFieldset = document.createElement('fieldset');
|
||||||
moduleFieldset.className = 'module-input-group';
|
moduleFieldset.className = 'module-input-group';
|
||||||
moduleFieldset.innerHTML = `
|
moduleFieldset.innerHTML = `
|
||||||
@ -347,15 +812,15 @@ function generateModuleInputs(count) {
|
|||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label for="module-${i}-name">Module Name</label>
|
<label for="module-${i}-name">Module Name</label>
|
||||||
<input type="text" id="module-${i}-name" value="Module ${i + 1}" required>
|
<input type="text" id="module-${i}-name" value="${position.name}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label for="module-${i}-x">X Position (pixels)</label>
|
<label for="module-${i}-x">X Position (pixels)</label>
|
||||||
<input type="number" id="module-${i}-x" step="1" value="0" required>
|
<input type="number" id="module-${i}-x" step="1" value="${position.x}" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label for="module-${i}-y">Y Position (pixels)</label>
|
<label for="module-${i}-y">Y Position (pixels)</label>
|
||||||
<input type="number" id="module-${i}-y" step="0.1" value="0" required>
|
<input type="number" id="module-${i}-y" step="0.1" value="${position.y}" required>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(moduleFieldset);
|
container.appendChild(moduleFieldset);
|
||||||
@ -418,12 +883,8 @@ function updateModuleDisplays(robot) {
|
|||||||
* BEGIN ANIMATION CODE
|
* BEGIN ANIMATION CODE
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Get the canvas and context as constants
|
const leftJoystick = new Joystick(ctx, -250, 250, 100);
|
||||||
const canvas = document.getElementById('swerve-canvas');
|
const rightJoystick = new Joystick(ctx, 250, 250, 100);
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
// Get CSS variables for use in canvas
|
|
||||||
const rootStyles = getComputedStyle(document.documentElement);
|
|
||||||
|
|
||||||
function drawGrid(ctx, sideLength, gridSquareSize, xOffset, yOffset) {
|
function drawGrid(ctx, sideLength, gridSquareSize, xOffset, yOffset) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
@ -528,8 +989,12 @@ function drawRobot(ctx, robot, heading) {
|
|||||||
ctx.restore(); // Restore to remove rotation
|
ctx.restore(); // Restore to remove rotation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Initialize Variables
|
// Initialize Variables
|
||||||
|
// Joysticks
|
||||||
|
const supportsTouch = (navigator.maxTouchPoints > 0);
|
||||||
|
|
||||||
|
|
||||||
|
// General robot
|
||||||
const robotSize = 200;
|
const robotSize = 200;
|
||||||
const defaultModulePositions = PresetConfigs.fourWheelSquare(robotSize);
|
const defaultModulePositions = PresetConfigs.fourWheelSquare(robotSize);
|
||||||
const robot = new SwerveDrive(defaultModulePositions, "4-Wheel Square");
|
const robot = new SwerveDrive(defaultModulePositions, "4-Wheel Square");
|
||||||
@ -549,28 +1014,58 @@ function animate() {
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.translate(canvas.width / 2, canvas.height / 2);
|
ctx.translate(canvas.width / 2, canvas.height / 2);
|
||||||
|
|
||||||
// Update speeds based on sliders
|
// Update speeds based on control mode
|
||||||
xSpeed = parseFloat(vxSlider.value);
|
if (isManualInputMode) {
|
||||||
ySpeed = -parseFloat(vySlider.value);
|
const maxSpeed = parseFloat(keyboardMaxSpeed.value);
|
||||||
turnSpeed = parseFloat(omegaSlider.value);
|
const maxRotation = parseFloat(keyboardMaxRotation.value);
|
||||||
|
// xSpeed = manualInputVelX;
|
||||||
|
// ySpeed = -manualInputVelY; // Negative because canvas Y axis is inverted
|
||||||
|
// turnSpeed = manualInputOmega;
|
||||||
|
|
||||||
// Animate the grid with robot movement
|
xSpeed = leftJoystick.getX() * maxSpeed;
|
||||||
let offsetSpeedDivisor = (100 - gridSquareSize <= 0 ? 1 : 100 - gridSquareSize);
|
ySpeed = -leftJoystick.getY() * maxSpeed;
|
||||||
|
turnSpeed = rightJoystick.getX() * maxRotation;
|
||||||
// Update grid offsets based on robot movement
|
} else {
|
||||||
xGridOffset = (xGridOffset + (xSpeed / offsetSpeedDivisor)) % gridSquareSize;
|
xSpeed = parseFloat(vxSlider.value);
|
||||||
yGridOffset = (yGridOffset + (ySpeed / offsetSpeedDivisor)) % gridSquareSize;
|
ySpeed = -parseFloat(vySlider.value);
|
||||||
|
turnSpeed = parseFloat(omegaSlider.value);
|
||||||
|
}
|
||||||
|
|
||||||
// Update module states before drawing the robot
|
// Update module states before drawing the robot
|
||||||
// The drive() method will update the gyroHeading internally
|
// The drive() method will update the gyroHeading internally
|
||||||
robot.drive(xSpeed, ySpeed, turnSpeed, parseFloat(maxSpeedSlider.value));
|
robot.drive(xSpeed, ySpeed, turnSpeed, parseFloat(maxSpeedSlider.value));
|
||||||
updateModuleDisplays(robot);
|
updateModuleDisplays(robot);
|
||||||
|
|
||||||
|
// Get the actual robot velocity (after scaling to max module speed) for grid animation
|
||||||
|
const actualVelocity = robot.getActualVelocity();
|
||||||
|
|
||||||
|
// Update control outputs with actual speeds
|
||||||
|
if (isManualInputMode) {
|
||||||
|
// In manual input mode (keyboard/gamepad), show the current values
|
||||||
|
keyboardMaxSpeedOutput.textContent = `Max: ${keyboardMaxSpeed.value} | Current: ${Math.max(Math.abs(actualVelocity.x), Math.abs(actualVelocity.y)).toFixed(1)}`;
|
||||||
|
keyboardMaxRotationOutput.textContent = `Max: ${keyboardMaxRotation.value} | Current: ${Math.abs(robot.actualTurnSpeed || 0).toFixed(2)}`;
|
||||||
|
} else {
|
||||||
|
// In slider mode, show requested vs actual
|
||||||
|
vxOutput.textContent = `Requested: ${vxSlider.value} | Actual: ${actualVelocity.x.toFixed(2)}`;
|
||||||
|
vyOutput.textContent = `Requested: ${vySlider.value} | Actual: ${-actualVelocity.y.toFixed(2)}`;
|
||||||
|
omegaOutput.textContent = `Requested: ${omegaSlider.value} | Actual: ${(robot.actualTurnSpeed || 0).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate the grid
|
||||||
|
let offsetSpeedDivisor = (100 - gridSquareSize <= 0 ? 1 : 100 - gridSquareSize);
|
||||||
|
|
||||||
|
// Update grid offsets based on robot movement
|
||||||
|
xGridOffset = (xGridOffset + (actualVelocity.x / offsetSpeedDivisor)) % gridSquareSize;
|
||||||
|
yGridOffset = (yGridOffset + (actualVelocity.y / offsetSpeedDivisor)) % gridSquareSize;
|
||||||
|
|
||||||
// Draw the robot and it's movement. Grid should be oversized so movement
|
// Draw the robot and it's movement. Grid should be oversized so movement
|
||||||
// doesn't find the edge of the grid
|
// doesn't find the edge of the grid
|
||||||
drawGrid(ctx, canvas.width * 2, gridSquareSize, xGridOffset, yGridOffset);
|
drawGrid(ctx, canvas.width * 2, gridSquareSize, xGridOffset, yGridOffset);
|
||||||
drawRobot(ctx, robot, robot.gyroHeading);
|
drawRobot(ctx, robot, robot.gyroHeading);
|
||||||
|
|
||||||
|
leftJoystick.draw();
|
||||||
|
rightJoystick.draw();
|
||||||
|
|
||||||
// Do it all over again
|
// Do it all over again
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
|
|||||||
16
styles.css
16
styles.css
@ -238,7 +238,7 @@ tr:hover {
|
|||||||
|
|
||||||
.visualization-area {
|
.visualization-area {
|
||||||
grid-column: 1 / 2;
|
grid-column: 1 / 2;
|
||||||
grid-row: 1 / 3;
|
grid-row: 2 / 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
#swerve-canvas {
|
#swerve-canvas {
|
||||||
@ -252,22 +252,22 @@ tr:hover {
|
|||||||
|
|
||||||
.controls-panel {
|
.controls-panel {
|
||||||
grid-column: 1 / 2;
|
grid-column: 1 / 2;
|
||||||
grid-row: 2 / 3;
|
grid-row: 3 / 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-panel {
|
.config-panel {
|
||||||
grid-column: 2 / 3;
|
grid-column: 2 / 3;
|
||||||
grid-row: 1 / 2;
|
grid-row: 2 / 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-states {
|
.module-states {
|
||||||
grid-column: 2 / 3;
|
grid-column: 2 / 3;
|
||||||
grid-row: 2 / 3;
|
grid-row: 3 / 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.documentation {
|
.documentation {
|
||||||
grid-column: 1 / 3;
|
grid-column: 1 / 3;
|
||||||
grid-row: 4 / 5;
|
grid-row: 1 / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
@ -362,3 +362,9 @@ button:hover {
|
|||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
main {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
sw.js
Normal file
33
sw.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
const CACHE_NAME = 'v1';
|
||||||
|
const ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/script.js',
|
||||||
|
'/styles.css',
|
||||||
|
'/manifest.json',
|
||||||
|
'/icons/192.svg',
|
||||||
|
'/icons/512.svg',
|
||||||
|
'/vendor/lucio/graham-scan.mjs'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
|
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(e.request).then(response => response || fetch(e.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user