brickremote 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brickremote/__init__.py +8 -0
- brickremote/car.py +40 -0
- brickremote/cli.py +131 -0
- brickremote/module.py +208 -0
- brickremote/protocol.py +90 -0
- brickremote/script.py +159 -0
- brickremote/tui.py +661 -0
- brickremote/web/index.html +611 -0
- brickremote/web.py +243 -0
- brickremote-0.3.0.dist-info/METADATA +196 -0
- brickremote-0.3.0.dist-info/RECORD +14 -0
- brickremote-0.3.0.dist-info/WHEEL +4 -0
- brickremote-0.3.0.dist-info/entry_points.txt +2 -0
- brickremote-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
6
|
+
<meta name="theme-color" content="#111">
|
|
7
|
+
<title>Brick Remote</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #121212; --panel: #1d1d1d; --panel2: #262626; --line: #333;
|
|
11
|
+
--text: #f4f4f4; --dim: #9a9a9a;
|
|
12
|
+
--green: #8ed11f; --green-dark: #5f9410; --orange: #ff8c1a; --red: #ff4b4b;
|
|
13
|
+
--yellow: #ffd23f; --blue: #3fa9ff;
|
|
14
|
+
--radius: 22px;
|
|
15
|
+
}
|
|
16
|
+
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
|
17
|
+
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
|
|
18
|
+
font-family: ui-rounded, "SF Pro Rounded", "Nunito", "Varela Round", system-ui, sans-serif; }
|
|
19
|
+
body { display: flex; flex-direction: column; user-select: none; -webkit-user-select: none; overflow: hidden; }
|
|
20
|
+
button { font: inherit; color: inherit; cursor: pointer; border: 0; }
|
|
21
|
+
|
|
22
|
+
/* header */
|
|
23
|
+
header { display: flex; align-items: center; gap: 14px; padding: 10px 16px; background: #0b0b0b;
|
|
24
|
+
border-bottom: 3px solid var(--green); flex-wrap: wrap; }
|
|
25
|
+
.logo { display: flex; align-items: center; gap: 10px; font-weight: 900; font-size: 22px; letter-spacing: .5px; }
|
|
26
|
+
.logo svg { width: 46px; height: 32px; }
|
|
27
|
+
nav { display: flex; gap: 8px; flex: 1; flex-wrap: wrap; }
|
|
28
|
+
nav button { background: var(--panel2); padding: 10px 18px; border-radius: 999px; font-weight: 800; font-size: 17px;
|
|
29
|
+
color: var(--dim); transition: transform .1s, background .2s; }
|
|
30
|
+
nav button.on { background: var(--green); color: #111; transform: scale(1.06); }
|
|
31
|
+
.battery { display: flex; align-items: center; gap: 8px; font-weight: 800; }
|
|
32
|
+
.battery .cell { width: 44px; height: 22px; border: 3px solid currentColor; border-radius: 6px; padding: 2px; position: relative; }
|
|
33
|
+
.battery .cell::after { content: ""; position: absolute; right: -7px; top: 5px; width: 4px; height: 8px; background: currentColor; border-radius: 0 2px 2px 0; }
|
|
34
|
+
.battery .fill { height: 100%; border-radius: 2px; background: currentColor; width: 0; }
|
|
35
|
+
|
|
36
|
+
/* layout */
|
|
37
|
+
main { flex: 1; position: relative; overflow: auto; }
|
|
38
|
+
section { display: none; height: 100%; padding: 18px; }
|
|
39
|
+
section.on { display: block; }
|
|
40
|
+
.row { display: flex; gap: 18px; align-items: center; justify-content: center; flex-wrap: wrap; height: 100%; }
|
|
41
|
+
.col { display: flex; flex-direction: column; gap: 14px; align-items: center; }
|
|
42
|
+
.panel { background: var(--panel); border-radius: var(--radius); padding: 16px; }
|
|
43
|
+
.label { font-weight: 900; letter-spacing: 1px; font-size: 15px; color: var(--dim); text-transform: uppercase; }
|
|
44
|
+
|
|
45
|
+
/* big buttons */
|
|
46
|
+
.big { width: 128px; height: 108px; border-radius: 28px; background: var(--panel2); font-size: 46px; font-weight: 900;
|
|
47
|
+
box-shadow: 0 7px 0 #0a0a0a, inset 0 0 0 4px var(--green); color: var(--green);
|
|
48
|
+
transition: transform .06s, box-shadow .06s, background .1s; touch-action: none; }
|
|
49
|
+
.big.orange { box-shadow: 0 7px 0 #0a0a0a, inset 0 0 0 4px var(--orange); color: var(--orange); }
|
|
50
|
+
.big.down { transform: translateY(6px); box-shadow: 0 1px 0 #0a0a0a, inset 0 0 0 4px var(--green); background: var(--green); color: #111; }
|
|
51
|
+
.big.orange.down { background: var(--orange); box-shadow: 0 1px 0 #0a0a0a, inset 0 0 0 4px var(--orange); }
|
|
52
|
+
.big small { display: block; font-size: 14px; letter-spacing: 1px; }
|
|
53
|
+
|
|
54
|
+
/* gears */
|
|
55
|
+
.gears { display: flex; flex-direction: column; gap: 10px; }
|
|
56
|
+
.gears button { width: 74px; height: 64px; border-radius: 18px; background: var(--panel2); font-size: 34px; font-weight: 900; color: var(--dim);
|
|
57
|
+
box-shadow: 0 5px 0 #0a0a0a; }
|
|
58
|
+
.gears button.on { background: var(--green); color: #111; }
|
|
59
|
+
|
|
60
|
+
/* steering wheel */
|
|
61
|
+
.wheel { width: min(46vh, 380px); aspect-ratio: 1; touch-action: none; cursor: grab;
|
|
62
|
+
filter: drop-shadow(0 10px 18px rgba(0,0,0,.6)); }
|
|
63
|
+
.wheel:active { cursor: grabbing; }
|
|
64
|
+
.wheel g { transition: transform .05s linear; transform-origin: 100px 100px; }
|
|
65
|
+
.steer-label { font-weight: 900; font-size: 22px; letter-spacing: 2px; height: 28px; }
|
|
66
|
+
|
|
67
|
+
/* meters */
|
|
68
|
+
.meter { width: 220px; height: 22px; border-radius: 11px; background: var(--panel2); overflow: hidden; position: relative; }
|
|
69
|
+
.meter .bar { position: absolute; top: 0; bottom: 0; left: 50%; background: var(--green); transition: all .15s; }
|
|
70
|
+
.meter .mid { position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; background: #555; }
|
|
71
|
+
.meter-row { display: flex; align-items: center; gap: 10px; font-weight: 900; }
|
|
72
|
+
.meter-row .tag { color: var(--yellow); font-size: 13px; min-width: 80px; }
|
|
73
|
+
|
|
74
|
+
/* joysticks */
|
|
75
|
+
.stick { width: min(38vh, 300px); aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle, #2a2a2a 0 55%, #1b1b1b 56%);
|
|
76
|
+
border: 4px solid var(--green); position: relative; touch-action: none; }
|
|
77
|
+
.stick .knob { position: absolute; width: 34%; height: 34%; left: 33%; top: 33%; border-radius: 50%;
|
|
78
|
+
background: radial-gradient(circle at 35% 35%, #bdf35a, var(--green-dark)); box-shadow: 0 6px 14px rgba(0,0,0,.6); }
|
|
79
|
+
.stick .lab { position: absolute; font-weight: 900; font-size: 22px; color: var(--green); }
|
|
80
|
+
.stick .n { top: 6px; left: 50%; transform: translateX(-50%); } .stick .s { bottom: 6px; left: 50%; transform: translateX(-50%); }
|
|
81
|
+
.stick .w { left: 10px; top: 50%; transform: translateY(-50%); } .stick .e { right: 10px; top: 50%; transform: translateY(-50%); }
|
|
82
|
+
|
|
83
|
+
/* toggles */
|
|
84
|
+
.chips { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
|
85
|
+
.chip { padding: 10px 16px; border-radius: 999px; background: var(--panel2); font-weight: 800; color: var(--dim); }
|
|
86
|
+
.chip.on { background: var(--yellow); color: #111; }
|
|
87
|
+
|
|
88
|
+
/* program */
|
|
89
|
+
.editor-wrap { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(220px, 300px); gap: 18px; max-width: 1000px; margin: 0 auto; }
|
|
90
|
+
textarea { width: 100%; min-height: 300px; background: #0d0d0d; color: var(--text); border: 3px solid var(--green); border-radius: 18px;
|
|
91
|
+
padding: 14px; font: 18px/1.5 ui-monospace, "SF Mono", Menlo, monospace; resize: vertical; user-select: text; -webkit-user-select: text; }
|
|
92
|
+
.blocks { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
93
|
+
.block { padding: 12px 8px; border-radius: 14px; font-weight: 800; color: #111; box-shadow: 0 4px 0 rgba(0,0,0,.5); }
|
|
94
|
+
.block.motion { background: var(--blue); } .block.control { background: var(--orange); } .block.speed { background: var(--green); }
|
|
95
|
+
.actions { display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
|
|
96
|
+
.action { flex: 1; padding: 16px; border-radius: 18px; font-size: 20px; font-weight: 900; box-shadow: 0 5px 0 #0a0a0a; }
|
|
97
|
+
.action.run { background: var(--green); color: #111; } .action.edit { background: var(--panel2); }
|
|
98
|
+
.action.stop { background: var(--red); color: #fff; }
|
|
99
|
+
|
|
100
|
+
/* path */
|
|
101
|
+
.path-wrap { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(220px, 300px); gap: 18px; max-width: 1100px; margin: 0 auto; }
|
|
102
|
+
canvas { width: 100%; background: var(--green); border-radius: 18px; touch-action: none; display: block; cursor: crosshair; }
|
|
103
|
+
.slider { display: block; font-weight: 800; margin: 6px 0; }
|
|
104
|
+
input[type=range] { width: 100%; accent-color: var(--green); }
|
|
105
|
+
|
|
106
|
+
/* footer */
|
|
107
|
+
footer { display: flex; align-items: center; gap: 16px; padding: 10px 16px; background: #0b0b0b; border-top: 3px solid var(--green); }
|
|
108
|
+
.stopall { background: var(--red); color: #fff; font-weight: 900; font-size: 22px; padding: 12px 34px; border-radius: 999px;
|
|
109
|
+
box-shadow: 0 5px 0 #6b0000; letter-spacing: 2px; }
|
|
110
|
+
.stopall:active { transform: translateY(4px); box-shadow: 0 1px 0 #6b0000; }
|
|
111
|
+
.msg { flex: 1; font-weight: 800; color: var(--yellow); font-size: 18px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
112
|
+
.hint { color: var(--dim); font-size: 14px; }
|
|
113
|
+
|
|
114
|
+
/* searching overlay */
|
|
115
|
+
.overlay { position: fixed; inset: 0; background: rgba(10,10,10,.94); display: flex; flex-direction: column; align-items: center;
|
|
116
|
+
justify-content: center; gap: 22px; z-index: 10; text-align: center; padding: 20px; }
|
|
117
|
+
.overlay.hidden { display: none; }
|
|
118
|
+
.overlay h1 { margin: 0; font-size: clamp(28px, 5vw, 46px); color: var(--yellow); }
|
|
119
|
+
.overlay p { margin: 0; font-size: 20px; color: var(--dim); }
|
|
120
|
+
.brick { display: flex; gap: 10px; padding: 0 14px 0; position: relative; }
|
|
121
|
+
.brick::after { content: ""; position: absolute; left: 0; right: 0; bottom: -54px; height: 64px; background: var(--green); border-radius: 10px; }
|
|
122
|
+
.stud { width: 52px; height: 30px; border-radius: 10px 10px 4px 4px; animation: hop 1.2s infinite ease-in-out; }
|
|
123
|
+
.stud:nth-child(2) { animation-delay: .15s; } .stud:nth-child(3) { animation-delay: .3s; } .stud:nth-child(4) { animation-delay: .45s; }
|
|
124
|
+
@keyframes hop { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-16px); } }
|
|
125
|
+
|
|
126
|
+
@media (max-width: 720px) {
|
|
127
|
+
.editor-wrap, .path-wrap { grid-template-columns: 1fr; }
|
|
128
|
+
.big { width: 96px; height: 84px; font-size: 36px; }
|
|
129
|
+
.logo span { display: none; }
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
132
|
+
</head>
|
|
133
|
+
<body>
|
|
134
|
+
<header>
|
|
135
|
+
<div class="logo">
|
|
136
|
+
<svg viewBox="0 0 46 32" aria-hidden="true">
|
|
137
|
+
<rect x="1" y="12" width="44" height="19" rx="4" fill="#8ed11f"/>
|
|
138
|
+
<rect x="3" y="4" width="8" height="9" rx="2" fill="#ff4b4b"/><rect x="14" y="4" width="8" height="9" rx="2" fill="#ffd23f"/>
|
|
139
|
+
<rect x="25" y="4" width="8" height="9" rx="2" fill="#3fa9ff"/><rect x="36" y="4" width="8" height="9" rx="2" fill="#ff8c1a"/>
|
|
140
|
+
</svg>
|
|
141
|
+
<span>BRICK REMOTE</span>
|
|
142
|
+
</div>
|
|
143
|
+
<nav>
|
|
144
|
+
<button data-tab="car" class="on">🚗 Car</button>
|
|
145
|
+
<button data-tab="ports">🕹️ Ports</button>
|
|
146
|
+
<button data-tab="program">🧩 Program</button>
|
|
147
|
+
<button data-tab="path">🗺️ Path</button>
|
|
148
|
+
</nav>
|
|
149
|
+
<div class="battery" id="battery" title="battery"><div class="cell"><div class="fill"></div></div><b>?%</b></div>
|
|
150
|
+
</header>
|
|
151
|
+
|
|
152
|
+
<main>
|
|
153
|
+
<!-- CAR -->
|
|
154
|
+
<section id="car" class="on">
|
|
155
|
+
<div class="row">
|
|
156
|
+
<div class="col">
|
|
157
|
+
<button class="big" data-hold="drive:1">▲<small>FORWARD</small></button>
|
|
158
|
+
<button class="big orange" data-hold="drive:-1">▼<small>BACK</small></button>
|
|
159
|
+
<div class="meter-row">MOTOR <div class="meter"><div class="mid"></div><div class="bar" id="carMeter"></div></div></div>
|
|
160
|
+
</div>
|
|
161
|
+
<div class="col">
|
|
162
|
+
<svg class="wheel" id="wheel" viewBox="0 0 200 200" aria-label="steering wheel">
|
|
163
|
+
<g id="wheelG">
|
|
164
|
+
<circle cx="100" cy="100" r="88" fill="none" stroke="#8ed11f" stroke-width="20"/>
|
|
165
|
+
<circle cx="100" cy="100" r="88" fill="none" stroke="#5f9410" stroke-width="4" stroke-dasharray="8 14"/>
|
|
166
|
+
<path d="M18 100 H74 M126 100 H182 M100 126 V188" stroke="#8ed11f" stroke-width="18" stroke-linecap="round"/>
|
|
167
|
+
<circle cx="100" cy="100" r="28" fill="#1d1d1d" stroke="#8ed11f" stroke-width="10"/>
|
|
168
|
+
<circle cx="100" cy="100" r="9" fill="#ff8c1a"/>
|
|
169
|
+
<rect x="94" y="6" width="12" height="16" rx="4" fill="#ff8c1a"/>
|
|
170
|
+
</g>
|
|
171
|
+
</svg>
|
|
172
|
+
<div class="steer-label" id="steerLabel">STRAIGHT</div>
|
|
173
|
+
<div class="hint">drag the wheel, or use the arrow keys</div>
|
|
174
|
+
</div>
|
|
175
|
+
<div class="col">
|
|
176
|
+
<div class="label">Speed</div>
|
|
177
|
+
<div class="gears" id="gears"><button data-gear="1">1</button><button data-gear="2">2</button><button data-gear="3">3</button></div>
|
|
178
|
+
</div>
|
|
179
|
+
<div class="col">
|
|
180
|
+
<div style="display:flex; gap:12px">
|
|
181
|
+
<button class="big" data-hold="steer:1">◀<small>LEFT</small></button>
|
|
182
|
+
<button class="big" data-hold="steer:-1">▶<small>RIGHT</small></button>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</section>
|
|
187
|
+
|
|
188
|
+
<!-- PORTS -->
|
|
189
|
+
<section id="ports">
|
|
190
|
+
<div class="row">
|
|
191
|
+
<div class="col">
|
|
192
|
+
<div class="stick" data-v="a" data-h="b"><span class="lab n">A▲</span><span class="lab s">A▼</span><span class="lab w">◀B</span><span class="lab e">B▶</span><div class="knob"></div></div>
|
|
193
|
+
<div class="meter-row">A <div class="meter"><div class="mid"></div><div class="bar" data-meter="a"></div></div><span class="tag" data-tag="a"></span></div>
|
|
194
|
+
<div class="meter-row">B <div class="meter"><div class="mid"></div><div class="bar" data-meter="b"></div></div><span class="tag" data-tag="b"></span></div>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="col panel">
|
|
197
|
+
<div class="label">Speed</div>
|
|
198
|
+
<div class="gears" id="gears2"><button data-gear="1">1</button><button data-gear="2">2</button><button data-gear="3">3</button></div>
|
|
199
|
+
<div class="label" style="margin-top:8px">Pair</div>
|
|
200
|
+
<div class="chips" id="pair"><button class="chip on" data-pair="">off</button><button class="chip" data-pair="a:b">A+B</button><button class="chip" data-pair="a:c">A+C</button></div>
|
|
201
|
+
<div class="hint" style="max-width:200px;text-align:center">paired ports always do the same thing</div>
|
|
202
|
+
<div class="label" style="margin-top:8px">Reverse</div>
|
|
203
|
+
<div class="chips" id="reverse"><button class="chip" data-rev="a">A</button><button class="chip" data-rev="b">B</button><button class="chip" data-rev="c">C</button><button class="chip" data-rev="d">D</button></div>
|
|
204
|
+
<div class="hint" style="max-width:200px;text-align:center">for a motor that is built in the other way round</div>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="col">
|
|
207
|
+
<div class="stick" data-v="c" data-h="d"><span class="lab n">C▲</span><span class="lab s">C▼</span><span class="lab w">◀D</span><span class="lab e">D▶</span><div class="knob"></div></div>
|
|
208
|
+
<div class="meter-row">C <div class="meter"><div class="mid"></div><div class="bar" data-meter="c"></div></div><span class="tag" data-tag="c"></span></div>
|
|
209
|
+
<div class="meter-row">D <div class="meter"><div class="mid"></div><div class="bar" data-meter="d"></div></div><span class="tag" data-tag="d"></span></div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</section>
|
|
213
|
+
|
|
214
|
+
<!-- PROGRAM -->
|
|
215
|
+
<section id="program">
|
|
216
|
+
<div class="editor-wrap">
|
|
217
|
+
<div>
|
|
218
|
+
<div class="label">My program</div>
|
|
219
|
+
<textarea id="programText" spellcheck="false"></textarea>
|
|
220
|
+
<div class="actions">
|
|
221
|
+
<button class="action run" data-run="program">▶ RUN</button>
|
|
222
|
+
<button class="action edit" id="clearProgram">🧽 Clear</button>
|
|
223
|
+
<button class="action stop" data-stop>■ STOP</button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
<div class="panel">
|
|
227
|
+
<div class="label">Add a block</div>
|
|
228
|
+
<div class="blocks" style="margin-top:10px">
|
|
229
|
+
<button class="block motion" data-add="a + 1">A ▲ 1 s</button>
|
|
230
|
+
<button class="block motion" data-add="a - 1">A ▼ 1 s</button>
|
|
231
|
+
<button class="block motion" data-add="b + 1">B ▲ 1 s</button>
|
|
232
|
+
<button class="block motion" data-add="b - 1">B ▼ 1 s</button>
|
|
233
|
+
<button class="block motion" data-add="c + 1">C ▲ 1 s</button>
|
|
234
|
+
<button class="block motion" data-add="c - 1">C ▼ 1 s</button>
|
|
235
|
+
<button class="block motion" data-add="d + 1">D ▲ 1 s</button>
|
|
236
|
+
<button class="block motion" data-add="d - 1">D ▼ 1 s</button>
|
|
237
|
+
<button class="block control" data-add="wait 1">⏱ wait 1 s</button>
|
|
238
|
+
<button class="block control" data-add="stop">✋ stop</button>
|
|
239
|
+
<button class="block control" data-add="repeat 3">🔁 repeat 3</button>
|
|
240
|
+
<button class="block control" data-add="end">⤴ end</button>
|
|
241
|
+
<button class="block speed" data-add="gear 1">🐢 speed 1</button>
|
|
242
|
+
<button class="block speed" data-add="gear 3">🐇 speed 3</button>
|
|
243
|
+
</div>
|
|
244
|
+
<p class="hint">Tip: change the numbers in the program to make things longer or shorter.</p>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</section>
|
|
248
|
+
|
|
249
|
+
<!-- PATH -->
|
|
250
|
+
<section id="path">
|
|
251
|
+
<div class="path-wrap">
|
|
252
|
+
<div>
|
|
253
|
+
<div class="label">Draw a path — tap squares in a straight line from the car</div>
|
|
254
|
+
<canvas id="grid" width="800" height="560"></canvas>
|
|
255
|
+
<div class="actions">
|
|
256
|
+
<button class="action run" data-run="path">▶ GO!</button>
|
|
257
|
+
<button class="action edit" id="undoPath">↶ Undo</button>
|
|
258
|
+
<button class="action edit" id="clearPath">🧽 Clear</button>
|
|
259
|
+
<button class="action stop" data-stop>■ STOP</button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
<div class="panel">
|
|
263
|
+
<div class="label">Route</div>
|
|
264
|
+
<textarea id="pathText" spellcheck="false" style="min-height:180px"></textarea>
|
|
265
|
+
<p class="hint">1 square = 0.5 m</p>
|
|
266
|
+
<div class="label">Calibration</div>
|
|
267
|
+
<div class="slider">Speed: <span id="mpsVal"></span> m/s<input type="range" id="mps" min="0.1" max="2" step="0.05"></div>
|
|
268
|
+
<div class="slider">Turning: <span id="dpsVal"></span> °/s<input type="range" id="dps" min="20" max="360" step="5"></div>
|
|
269
|
+
<p class="hint">Measure how far your car goes in 1 second, and how fast it turns, then set them here.</p>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</section>
|
|
273
|
+
</main>
|
|
274
|
+
|
|
275
|
+
<footer>
|
|
276
|
+
<button class="stopall" data-stop>STOP</button>
|
|
277
|
+
<div class="msg" id="msg">Hi! Let's drive.</div>
|
|
278
|
+
<div class="hint">space = stop</div>
|
|
279
|
+
</footer>
|
|
280
|
+
|
|
281
|
+
<div class="overlay" id="overlay">
|
|
282
|
+
<div class="brick">
|
|
283
|
+
<div class="stud" style="background:#ff4b4b"></div><div class="stud" style="background:#ffd23f"></div>
|
|
284
|
+
<div class="stud" style="background:#3fa9ff"></div><div class="stud" style="background:#ff8c1a"></div>
|
|
285
|
+
</div>
|
|
286
|
+
<div style="height:60px"></div>
|
|
287
|
+
<h1 id="overlayTitle">Looking for your brick…</h1>
|
|
288
|
+
<p id="overlayText">Turn it on with the power button. The light blinks while it waits for us.</p>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<script>
|
|
292
|
+
"use strict";
|
|
293
|
+
addEventListener("error", e => { const m = document.getElementById("msg"); if (m) m.textContent = "Oops: " + e.message; });
|
|
294
|
+
const GEARS = [60, 120, 255];
|
|
295
|
+
const $ = (s, el = document) => el.querySelector(s);
|
|
296
|
+
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
|
|
297
|
+
|
|
298
|
+
let ws, state = null, settings = null;
|
|
299
|
+
const held = { drive: 0, steer: 0 }; // car controls
|
|
300
|
+
const sticks = { a: 0, b: 0, c: 0, d: 0 }; // ports, -1..1
|
|
301
|
+
let tab = "car", lastSent = "", wheelAngle = 0, wheelDrag = null;
|
|
302
|
+
|
|
303
|
+
// ---------- connection ----------
|
|
304
|
+
function connect() {
|
|
305
|
+
ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
|
|
306
|
+
ws.onmessage = e => { const m = JSON.parse(e.data); if (m.type === "state") update(m); };
|
|
307
|
+
ws.onclose = () => { state = null; showOverlay(true, "Lost the remote…", "Is brickremote still running on the computer? Trying again…"); setTimeout(connect, 1000); };
|
|
308
|
+
}
|
|
309
|
+
function send(msg) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg)); }
|
|
310
|
+
function saveSettings(part) { Object.assign(settings, part); send({ type: "settings", settings: part }); }
|
|
311
|
+
|
|
312
|
+
function showOverlay(on, title, text) {
|
|
313
|
+
$("#overlay").classList.toggle("hidden", !on);
|
|
314
|
+
if (title) $("#overlayTitle").textContent = title;
|
|
315
|
+
if (text) $("#overlayText").textContent = text;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------- state from server ----------
|
|
319
|
+
let firstState = true;
|
|
320
|
+
function update(m) {
|
|
321
|
+
state = m;
|
|
322
|
+
if (firstState) { settings = m.settings; loadSettings(); firstState = false; }
|
|
323
|
+
else { settings.links = m.settings.links; settings.inverted = m.settings.inverted; }
|
|
324
|
+
showOverlay(!m.connected, "Looking for your brick…", "Turn it on with the power button. The light blinks while it waits for us.");
|
|
325
|
+
const b = m.battery, bat = $("#battery");
|
|
326
|
+
bat.querySelector(".fill").style.width = (b ?? 0) + "%";
|
|
327
|
+
bat.querySelector("b").textContent = (b ?? "?") + "%";
|
|
328
|
+
bat.style.color = b == null ? "var(--dim)" : b > 50 ? "var(--green)" : b > 20 ? "var(--yellow)" : "var(--red)";
|
|
329
|
+
if (m.log.length) $("#msg").textContent = m.log[m.log.length - 1];
|
|
330
|
+
for (const p of "abcd") setMeter($(`[data-meter="${p}"]`), m.speeds[p]);
|
|
331
|
+
setMeter($("#carMeter"), m.speeds[settings.drive_port]);
|
|
332
|
+
const followers = Object.values(settings.links);
|
|
333
|
+
for (const p of "abcd") $(`[data-tag="${p}"]`).textContent = followers.includes(p) ? "paired" : settings.inverted.includes(p) ? "reversed" : "";
|
|
334
|
+
$$("[data-run]").forEach(btn => btn.style.opacity = m.running ? .6 : 1);
|
|
335
|
+
}
|
|
336
|
+
function setMeter(el, speed) {
|
|
337
|
+
if (!el) return;
|
|
338
|
+
const pct = Math.abs(speed) / 255 * 50;
|
|
339
|
+
el.style.width = pct + "%";
|
|
340
|
+
el.style.left = speed >= 0 ? "50%" : (50 - pct) + "%";
|
|
341
|
+
el.style.background = speed >= 0 ? "var(--green)" : "var(--orange)";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function loadSettings() {
|
|
345
|
+
setGear(settings.gear, false);
|
|
346
|
+
$("#programText").value = settings.program;
|
|
347
|
+
$("#pathText").value = settings.path;
|
|
348
|
+
route = routeFromScript(settings.path) || [start];
|
|
349
|
+
drawGrid();
|
|
350
|
+
$("#mps").value = settings.calibration.metres_per_second;
|
|
351
|
+
$("#dps").value = settings.calibration.degrees_per_second;
|
|
352
|
+
showCal();
|
|
353
|
+
const link = Object.entries(settings.links)[0];
|
|
354
|
+
$$("#pair .chip").forEach(c => c.classList.toggle("on", c.dataset.pair === (link ? link.join(":") : "")));
|
|
355
|
+
$$("#reverse .chip").forEach(c => c.classList.toggle("on", settings.inverted.includes(c.dataset.rev)));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------- tabs ----------
|
|
359
|
+
$$("nav button").forEach(btn => btn.onclick = () => selectTab(btn.dataset.tab));
|
|
360
|
+
function selectTab(name) {
|
|
361
|
+
if (!document.getElementById(name)) name = "car";
|
|
362
|
+
tab = name;
|
|
363
|
+
history.replaceState(null, "", "#" + name);
|
|
364
|
+
$$("nav button").forEach(b => b.classList.toggle("on", b.dataset.tab === name));
|
|
365
|
+
$$("section").forEach(s => s.classList.toggle("on", s.id === name));
|
|
366
|
+
releaseAll();
|
|
367
|
+
if (name === "path") drawGrid();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------- gears ----------
|
|
371
|
+
function setGear(g, save = true) {
|
|
372
|
+
settings.gear = g;
|
|
373
|
+
$$("[data-gear]").forEach(b => b.classList.toggle("on", +b.dataset.gear === g));
|
|
374
|
+
if (save) saveSettings({ gear: g });
|
|
375
|
+
}
|
|
376
|
+
$$("[data-gear]").forEach(b => b.onclick = () => setGear(+b.dataset.gear));
|
|
377
|
+
|
|
378
|
+
// ---------- hold buttons ----------
|
|
379
|
+
$$("[data-hold]").forEach(btn => {
|
|
380
|
+
const [axis, dir] = btn.dataset.hold.split(":");
|
|
381
|
+
const down = e => { e.preventDefault(); btn.setPointerCapture?.(e.pointerId); held[axis] = +dir; btn.classList.add("down"); };
|
|
382
|
+
const up = () => { if (held[axis] === +dir) held[axis] = 0; btn.classList.remove("down"); };
|
|
383
|
+
btn.addEventListener("pointerdown", down);
|
|
384
|
+
["pointerup", "pointercancel", "lostpointercapture"].forEach(ev => btn.addEventListener(ev, up));
|
|
385
|
+
});
|
|
386
|
+
function releaseAll() {
|
|
387
|
+
held.drive = held.steer = 0; for (const p in sticks) sticks[p] = 0;
|
|
388
|
+
$$(".big.down").forEach(b => b.classList.remove("down"));
|
|
389
|
+
$$(".knob").forEach(k => k.style.transform = "");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------- steering wheel drag ----------
|
|
393
|
+
const wheel = $("#wheel");
|
|
394
|
+
wheel.addEventListener("pointerdown", e => {
|
|
395
|
+
wheel.setPointerCapture(e.pointerId);
|
|
396
|
+
const r = wheel.getBoundingClientRect();
|
|
397
|
+
wheelDrag = { cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
|
|
398
|
+
wheelDrag.start = Math.atan2(e.clientY - wheelDrag.cy, e.clientX - wheelDrag.cx);
|
|
399
|
+
});
|
|
400
|
+
wheel.addEventListener("pointermove", e => {
|
|
401
|
+
if (!wheelDrag) return;
|
|
402
|
+
let d = Math.atan2(e.clientY - wheelDrag.cy, e.clientX - wheelDrag.cx) - wheelDrag.start;
|
|
403
|
+
d = Math.atan2(Math.sin(d), Math.cos(d)) * 180 / Math.PI; // -180..180, clockwise positive
|
|
404
|
+
held.steer = d < -15 ? 1 : d > 15 ? -1 : 0; // anticlockwise = left
|
|
405
|
+
wheelDrag.angle = Math.max(-90, Math.min(90, d));
|
|
406
|
+
});
|
|
407
|
+
["pointerup", "pointercancel"].forEach(ev => wheel.addEventListener(ev, () => { wheelDrag = null; held.steer = 0; }));
|
|
408
|
+
|
|
409
|
+
// ---------- joysticks ----------
|
|
410
|
+
$$(".stick").forEach(stick => {
|
|
411
|
+
const knob = $(".knob", stick), v = stick.dataset.v, h = stick.dataset.h;
|
|
412
|
+
const move = e => {
|
|
413
|
+
const r = stick.getBoundingClientRect(), R = r.width / 2;
|
|
414
|
+
let x = (e.clientX - r.left - R) / (R * .66), y = (e.clientY - r.top - R) / (R * .66);
|
|
415
|
+
const len = Math.hypot(x, y); if (len > 1) { x /= len; y /= len; }
|
|
416
|
+
knob.style.transform = `translate(${x * R * .66}px, ${y * R * .66}px)`;
|
|
417
|
+
sticks[v] = Math.abs(y) < .2 ? 0 : -y; sticks[h] = Math.abs(x) < .2 ? 0 : x;
|
|
418
|
+
};
|
|
419
|
+
stick.addEventListener("pointerdown", e => { stick.setPointerCapture(e.pointerId); move(e); });
|
|
420
|
+
stick.addEventListener("pointermove", e => { if (stick.hasPointerCapture(e.pointerId)) move(e); });
|
|
421
|
+
["pointerup", "pointercancel"].forEach(ev => stick.addEventListener(ev, () => { knob.style.transform = ""; sticks[v] = sticks[h] = 0; }));
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ---------- keyboard ----------
|
|
425
|
+
const keyMap = { ArrowUp: ["car", "drive", 1], ArrowDown: ["car", "drive", -1], ArrowLeft: ["car", "steer", 1], ArrowRight: ["car", "steer", -1],
|
|
426
|
+
w: ["ports", "a", 1], s: ["ports", "a", -1], d: ["ports", "b", 1], a: ["ports", "b", -1],
|
|
427
|
+
i: ["ports", "c", 1], k: ["ports", "c", -1], l: ["ports", "d", 1], j: ["ports", "d", -1] };
|
|
428
|
+
addEventListener("keydown", e => {
|
|
429
|
+
if (e.target.tagName === "TEXTAREA") return;
|
|
430
|
+
if (e.key === " ") { e.preventDefault(); stopAll(); return; }
|
|
431
|
+
if ("123".includes(e.key) && e.key) { setGear(+e.key); return; }
|
|
432
|
+
const k = keyMap[e.key] || keyMap[e.key.toLowerCase()];
|
|
433
|
+
if (!k || k[0] !== tab) return;
|
|
434
|
+
e.preventDefault();
|
|
435
|
+
if (tab === "car") held[k[1]] = k[2]; else sticks[k[1]] = k[2];
|
|
436
|
+
});
|
|
437
|
+
addEventListener("keyup", e => {
|
|
438
|
+
const k = keyMap[e.key] || keyMap[e.key.toLowerCase()];
|
|
439
|
+
if (!k) return;
|
|
440
|
+
if (k[0] === "car" && held[k[1]] === k[2]) held[k[1]] = 0;
|
|
441
|
+
if (k[0] === "ports" && sticks[k[1]] === k[2]) sticks[k[1]] = 0;
|
|
442
|
+
});
|
|
443
|
+
addEventListener("blur", releaseAll);
|
|
444
|
+
document.addEventListener("visibilitychange", () => document.hidden && releaseAll());
|
|
445
|
+
|
|
446
|
+
// ---------- the drive loop ----------
|
|
447
|
+
function wanted() {
|
|
448
|
+
const speed = GEARS[settings.gear - 1];
|
|
449
|
+
if (tab === "car") {
|
|
450
|
+
return { [settings.drive_port]: held.drive * speed, [settings.steer_port]: held.steer * 255 };
|
|
451
|
+
}
|
|
452
|
+
if (tab === "ports") {
|
|
453
|
+
const followers = Object.values(settings.links), out = {};
|
|
454
|
+
for (const p of "abcd") if (!followers.includes(p)) out[p] = Math.round(sticks[p] * speed);
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
setInterval(() => {
|
|
460
|
+
if (!settings || !state || !state.connected || state.running) return;
|
|
461
|
+
const want = wanted();
|
|
462
|
+
if (!want) return;
|
|
463
|
+
const json = JSON.stringify(want), moving = Object.values(want).some(v => v);
|
|
464
|
+
if (moving || json !== lastSent) send({ type: "drive", speeds: want }); // repeat while moving (dead-man)
|
|
465
|
+
lastSent = json;
|
|
466
|
+
}, 100);
|
|
467
|
+
|
|
468
|
+
// wheel animation: follows the drag, or springs to the steering direction
|
|
469
|
+
(function animate() {
|
|
470
|
+
const target = wheelDrag && wheelDrag.angle != null ? wheelDrag.angle : -held.steer * 60;
|
|
471
|
+
wheelAngle += (target - wheelAngle) * (wheelDrag ? .6 : .2);
|
|
472
|
+
$("#wheelG").style.transform = `rotate(${wheelAngle}deg)`;
|
|
473
|
+
$("#steerLabel").textContent = held.steer > 0 ? "◀ LEFT" : held.steer < 0 ? "RIGHT ▶" : "STRAIGHT";
|
|
474
|
+
requestAnimationFrame(animate);
|
|
475
|
+
})();
|
|
476
|
+
|
|
477
|
+
// ---------- stop ----------
|
|
478
|
+
function stopAll() { releaseAll(); send({ type: "stop" }); }
|
|
479
|
+
$$("[data-stop]").forEach(b => b.onclick = stopAll);
|
|
480
|
+
|
|
481
|
+
// ---------- pair / reverse ----------
|
|
482
|
+
$$("#pair .chip").forEach(c => c.onclick = () => {
|
|
483
|
+
const links = c.dataset.pair ? Object.fromEntries([c.dataset.pair.split(":")]) : {};
|
|
484
|
+
$$("#pair .chip").forEach(x => x.classList.toggle("on", x === c));
|
|
485
|
+
saveSettings({ links });
|
|
486
|
+
});
|
|
487
|
+
$$("#reverse .chip").forEach(c => c.onclick = () => {
|
|
488
|
+
c.classList.toggle("on");
|
|
489
|
+
saveSettings({ inverted: $$("#reverse .chip.on").map(x => x.dataset.rev) });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// ---------- program ----------
|
|
493
|
+
const programText = $("#programText");
|
|
494
|
+
let saveTimer;
|
|
495
|
+
programText.addEventListener("input", () => { clearTimeout(saveTimer); saveTimer = setTimeout(() => saveSettings({ program: programText.value }), 300); });
|
|
496
|
+
$$("[data-add]").forEach(b => b.onclick = () => {
|
|
497
|
+
const lines = programText.value.replace(/\s+$/, "").split("\n");
|
|
498
|
+
const depth = lines.reduce((d, l) => { const w = l.trim().split(/\s+/)[0]; return w === "repeat" || w === "forever" ? d + 1 : w === "end" ? Math.max(0, d - 1) : d; }, 0);
|
|
499
|
+
const add = b.dataset.add, indent = " ".repeat(add === "end" ? Math.max(0, depth - 1) : depth);
|
|
500
|
+
programText.value = (programText.value.trim() ? programText.value.replace(/\s+$/, "") + "\n" : "") + indent + add + "\n";
|
|
501
|
+
saveSettings({ program: programText.value });
|
|
502
|
+
});
|
|
503
|
+
$("#clearProgram").onclick = () => { programText.value = ""; saveSettings({ program: "" }); };
|
|
504
|
+
$$("[data-run]").forEach(b => b.onclick = () => {
|
|
505
|
+
if (b.dataset.run === "program") saveSettings({ program: programText.value });
|
|
506
|
+
else saveSettings({ path: $("#pathText").value });
|
|
507
|
+
send({ type: b.dataset.run === "program" ? "run_program" : "run_path" });
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// ---------- path drawing ----------
|
|
511
|
+
const canvas = $("#grid"), ctx = canvas.getContext("2d");
|
|
512
|
+
const COLS = 16, ROWS = 11;
|
|
513
|
+
const start = { x: 7, y: 9 };
|
|
514
|
+
let route = [start];
|
|
515
|
+
|
|
516
|
+
function cell() { return canvas.width / COLS; }
|
|
517
|
+
function drawGrid() {
|
|
518
|
+
const c = cell();
|
|
519
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
520
|
+
ctx.strokeStyle = "rgba(255,255,255,.45)"; ctx.lineWidth = 1;
|
|
521
|
+
for (let i = 0; i <= COLS; i++) { ctx.beginPath(); ctx.moveTo(i * c, 0); ctx.lineTo(i * c, ROWS * c); ctx.stroke(); }
|
|
522
|
+
for (let j = 0; j <= ROWS; j++) { ctx.beginPath(); ctx.moveTo(0, j * c); ctx.lineTo(COLS * c, j * c); ctx.stroke(); }
|
|
523
|
+
ctx.strokeStyle = "#ff8c1a"; ctx.lineWidth = c * .32; ctx.lineCap = ctx.lineJoin = "round";
|
|
524
|
+
ctx.beginPath();
|
|
525
|
+
route.forEach((p, i) => { const X = (p.x + .5) * c, Y = (p.y + .5) * c; i ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y); });
|
|
526
|
+
ctx.stroke();
|
|
527
|
+
route.forEach((p, i) => {
|
|
528
|
+
ctx.fillStyle = i === route.length - 1 && i ? "#ff4b4b" : "#fff";
|
|
529
|
+
ctx.beginPath(); ctx.arc((p.x + .5) * c, (p.y + .5) * c, c * .14, 0, 7); ctx.fill();
|
|
530
|
+
});
|
|
531
|
+
drawCar((start.x + .5) * c, (start.y + .5) * c, c);
|
|
532
|
+
}
|
|
533
|
+
// A little top-down car pointing up, drawn by hand so it looks the same on every device.
|
|
534
|
+
function drawCar(x, y, c) {
|
|
535
|
+
const w = c * .5, h = c * .78;
|
|
536
|
+
ctx.save();
|
|
537
|
+
ctx.translate(x, y);
|
|
538
|
+
ctx.fillStyle = "#222"; // wheels
|
|
539
|
+
for (const [wx, wy] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) ctx.fillRect(wx * w * .52 - c * .06, wy * h * .28 - c * .09, c * .12, c * .18);
|
|
540
|
+
ctx.fillStyle = "#ff4b4b"; // body
|
|
541
|
+
ctx.beginPath(); ctx.roundRect(-w / 2, -h / 2, w, h, c * .14); ctx.fill();
|
|
542
|
+
ctx.fillStyle = "#bfe8ff"; // windscreen at the front
|
|
543
|
+
ctx.beginPath(); ctx.roundRect(-w * .36, -h * .3, w * .72, h * .2, c * .04); ctx.fill();
|
|
544
|
+
ctx.fillStyle = "#ffd23f"; // headlights
|
|
545
|
+
ctx.fillRect(-w * .42, -h * .5, w * .2, c * .05); ctx.fillRect(w * .22, -h * .5, w * .2, c * .05);
|
|
546
|
+
ctx.restore();
|
|
547
|
+
}
|
|
548
|
+
function routeToScript() {
|
|
549
|
+
const lines = ["# drawn on the grid - 1 square = 0.5 m"];
|
|
550
|
+
let heading = [0, -1]; // facing up
|
|
551
|
+
for (let i = 1; i < route.length; i++) {
|
|
552
|
+
const dx = Math.sign(route[i].x - route[i - 1].x), dy = Math.sign(route[i].y - route[i - 1].y);
|
|
553
|
+
const dist = (Math.abs(route[i].x - route[i - 1].x) + Math.abs(route[i].y - route[i - 1].y)) * .5;
|
|
554
|
+
const cross = heading[0] * dy - heading[1] * dx, dot = heading[0] * dx + heading[1] * dy;
|
|
555
|
+
if (dot < 0) lines.push("left 180");
|
|
556
|
+
else if (cross < 0) lines.push("left 90");
|
|
557
|
+
else if (cross > 0) lines.push("right 90");
|
|
558
|
+
heading = [dx, dy];
|
|
559
|
+
const last = lines[lines.length - 1];
|
|
560
|
+
if (last.startsWith("forward ")) lines[lines.length - 1] = "forward " + (parseFloat(last.slice(8)) + dist);
|
|
561
|
+
else lines.push("forward " + dist);
|
|
562
|
+
}
|
|
563
|
+
return lines.join("\n") + "\n";
|
|
564
|
+
}
|
|
565
|
+
// Draw a typed or saved route on the grid when it fits: whole squares and right-angle turns.
|
|
566
|
+
function routeFromScript(text) {
|
|
567
|
+
const pts = [start]; let [x, y] = [start.x, start.y], h = [0, -1];
|
|
568
|
+
for (const raw of text.split("\n")) {
|
|
569
|
+
const words = raw.split("#")[0].trim().split(/\s+/);
|
|
570
|
+
if (!words[0]) continue;
|
|
571
|
+
const n = parseFloat(words[1]);
|
|
572
|
+
if (words[0] === "left" || words[0] === "right") {
|
|
573
|
+
if (n % 90) return null;
|
|
574
|
+
for (let i = 0; i < n / 90; i++) h = words[0] === "left" ? [h[1], -h[0]] : [-h[1], h[0]];
|
|
575
|
+
} else if (words[0] === "forward" || words[0] === "back") {
|
|
576
|
+
const steps = n / .5; if (!Number.isInteger(steps)) return null;
|
|
577
|
+
const d = words[0] === "forward" ? 1 : -1;
|
|
578
|
+
x += h[0] * steps * d; y += h[1] * steps * d;
|
|
579
|
+
if (x < 0 || y < 0 || x >= COLS || y >= ROWS) return null;
|
|
580
|
+
pts.push({ x, y });
|
|
581
|
+
} else return null;
|
|
582
|
+
}
|
|
583
|
+
return pts;
|
|
584
|
+
}
|
|
585
|
+
function routeChanged() { drawGrid(); $("#pathText").value = routeToScript(); saveSettings({ path: $("#pathText").value }); }
|
|
586
|
+
canvas.addEventListener("pointerdown", e => {
|
|
587
|
+
const r = canvas.getBoundingClientRect(), scale = canvas.width / r.width;
|
|
588
|
+
const x = Math.floor((e.clientX - r.left) * scale / cell()), y = Math.floor((e.clientY - r.top) * scale / cell());
|
|
589
|
+
const last = route[route.length - 1];
|
|
590
|
+
if (x < 0 || y < 0 || x >= COLS || y >= ROWS || (x === last.x && y === last.y)) return;
|
|
591
|
+
if (x !== last.x && y !== last.y) { $("#msg").textContent = "Paths go in straight lines - tap a square in the same row or column."; return; }
|
|
592
|
+
route.push({ x, y }); routeChanged();
|
|
593
|
+
});
|
|
594
|
+
$("#undoPath").onclick = () => { if (route.length > 1) { route.pop(); routeChanged(); } };
|
|
595
|
+
$("#clearPath").onclick = () => { route = [start]; routeChanged(); };
|
|
596
|
+
$("#pathText").addEventListener("input", () => {
|
|
597
|
+
const pts = routeFromScript($("#pathText").value);
|
|
598
|
+
if (pts) { route = pts; drawGrid(); }
|
|
599
|
+
clearTimeout(saveTimer); saveTimer = setTimeout(() => saveSettings({ path: $("#pathText").value }), 300);
|
|
600
|
+
});
|
|
601
|
+
function showCal() { $("#mpsVal").textContent = (+$("#mps").value).toFixed(2); $("#dpsVal").textContent = $("#dps").value; }
|
|
602
|
+
["mps", "dps"].forEach(id => $("#" + id).addEventListener("input", () => {
|
|
603
|
+
showCal();
|
|
604
|
+
saveSettings({ calibration: { metres_per_second: +$("#mps").value, degrees_per_second: +$("#dps").value } });
|
|
605
|
+
}));
|
|
606
|
+
|
|
607
|
+
connect();
|
|
608
|
+
selectTab(location.hash.slice(1) || "car");
|
|
609
|
+
</script>
|
|
610
|
+
</body>
|
|
611
|
+
</html>
|