├── .gitignore
├── README.md
├── favicon.svg
├── favicon180.png
├── favicon192.png
├── favicon32.ico
├── favicon512.png
├── index.html
├── manifest.webmanifest
├── mightyThingsEncoder.js
├── mightythings-spiral.mov
├── mightythings.gif
├── screenshot.png
└── style.css
/.gitignore:
--------------------------------------------------------------------------------
1 | local stuff
2 | .DS_Store
3 | .nova
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mighty Things Encoder
2 |
3 | Make your own hidden parachute message like [NASA’s JPL did with Perseverance](https://mars.nasa.gov/resources/25646/mars-decoder-ring/).
4 |
5 | [](https://nole.li/EncodeMightyThings)
6 |
7 | You can check it out online at [nole.li/EncodeMightyThings](https://nole.li/EncodeMightyThings)!
8 |
--------------------------------------------------------------------------------
/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
324 |
--------------------------------------------------------------------------------
/favicon180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Noleli/EncodeMightyThings/37faeda3510ca05d6fdb2324b29e64192655841d/favicon180.png
--------------------------------------------------------------------------------
/favicon192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Noleli/EncodeMightyThings/37faeda3510ca05d6fdb2324b29e64192655841d/favicon192.png
--------------------------------------------------------------------------------
/favicon32.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Noleli/EncodeMightyThings/37faeda3510ca05d6fdb2324b29e64192655841d/favicon32.ico
--------------------------------------------------------------------------------
/favicon512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Noleli/EncodeMightyThings/37faeda3510ca05d6fdb2324b29e64192655841d/favicon512.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | Encode Mighty Things
24 |
25 |
26 |
27 |
28 |
29 |
30 |
71 |
72 |
73 |
74 |
75 |
89 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/manifest.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "icons": [
3 | { "src": "favicon192.png", "type": "image/png", "sizes": "192x192" },
4 | { "src": "favicon512.png", "type": "image/png", "sizes": "512x512" }
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/mightyThingsEncoder.js:
--------------------------------------------------------------------------------
1 | class ChuteVisualizer {
2 | constructor(containerSelector) {
3 | this.container = d3.select(containerSelector);
4 |
5 | this.svg = this.container.append("svg");
6 | this.radialContainer = this.svg.append("g");
7 | this.parachute = this.radialContainer.append("g");
8 | this.explanatory = this.radialContainer.append("g");
9 |
10 | this.roundedEdgeProportion = 1/12; // proportion of radius to treat as radius for curved arc edges
11 | this.margin = 2;
12 |
13 | // inner and outer radius of each ring as a proportion of the total radius
14 | this.ringProportions = [[0.09, 0.37], [0.37, 0.63], [0.63, 0.84], [0.92, 1]];
15 |
16 | // not doing this in CSS so it's a legit exportable SVG
17 | this.colors = {
18 | dataTrue: this.container.style("--dataTrue"),
19 | dataFalse: this.container.style("--dataFalse"),
20 | preWordPadding: this.container.style("--preWordPadding"),
21 | interBytePadding: this.container.style("--interBytePadding"),
22 | postWordPadding: this.container.style("--postWordPadding"),
23 | postDataPadding: this.container.style("--postDataPadding")
24 | };
25 |
26 | this.angle = d3.scaleLinear()
27 | .range([0, 2 * Math.PI]);
28 |
29 | this.size();
30 |
31 | this.windowWidth = 0;
32 | this.windowHeight = 0;
33 | d3.select(window).on("resize.vis", () => {
34 | // there's a mobile safari bug that fires resize events when scrolling,
35 | // so manually track the window size. https://stackoverflow.com/a/29940941
36 | const newWidth = document.documentElement.clientWidth;
37 | const newHeight = document.documentElement.clientHeight
38 | if(this.windowWidth !== newWidth || this.windowHeight != newHeight) {
39 | // catching a special case for rotating devices that have width 768.
40 | // gotta run it twice. kinda janky, but here we are. I think it
41 | // has to do with scrollbars.
42 | if(newWidth <= 768 && this.windowWidth > 768 || newWidth == 768) {
43 | this.size();
44 | this.update();
45 | }
46 | this.size();
47 | this.update();
48 | }
49 | });
50 | }
51 |
52 | updateData() {
53 | this.data = encoder.encodeAll(strings);
54 | this.update();
55 | }
56 |
57 | update() {
58 | this.angle
59 | .domain([0, this.data[0].length]); // in case the number of bits changed (although there's no UI for that)
60 |
61 | const rings = this.parachute.selectAll("g.ring").data(this.data);
62 | rings.join("g")
63 | .attr("class", "ring")
64 | .each((rowData, rowIndex, rowG) => {
65 | const row = d3.select(rowG[rowIndex]);
66 | let bits = row.selectAll("g.bit").data(d => d);
67 |
68 | const bitsEnter = bits.enter().append("g").attr("class", "bit");
69 | bitsEnter.append("path").attr("class", "bitPath");
70 | bits = bits.merge(bitsEnter);
71 |
72 | bits.select("path.bitPath")
73 | .attr("d", (b, bitIndex) => this.makePath(bitIndex, rowIndex))
74 | .attr("stroke", d => {
75 | const normalColor = "#111111";
76 | // const nonDataColor = "none";
77 | if(explain) {
78 | if(d.role == "data") {
79 | return normalColor;
80 | }
81 | else {
82 | return this.colors[d.role];
83 | }
84 | }
85 | else {
86 | return normalColor;
87 | }
88 | })
89 | .attr("stroke-width", this.radius/600)
90 | .attr("stroke-linejoin", "round")
91 | .attr("fill", d => {
92 | if(explain) {
93 | if(d.role == "data") {
94 | return d.value ? this.colors.dataTrue : this.colors.dataFalse;
95 | }
96 | else {
97 | return this.colors[d.role];
98 | }
99 | }
100 | else {
101 | return d.value ? this.colors.dataTrue : this.colors.dataFalse;
102 | }
103 | });
104 | });
105 |
106 | const explanatoryRings = this.explanatory.selectAll("g.ring").data(this.data);
107 | explanatoryRings.join("g").attr("class", "ring").each((rowData, rowIndex, rowG) => {
108 | const row = d3.select(rowG[rowIndex]);
109 | let bits = row.selectAll("g.bit").data(d => d);
110 |
111 | const bitsEnter = bits.enter().append("g").attr("class", "bit");
112 | bitsEnter.append("text").attr("class", "token");
113 | bits = bits.merge(bitsEnter);
114 |
115 | bits.select("text.token")
116 | .text(d => d.bit === 3 && explain ? d.token : "")
117 | .attr("font-family", "Helvetica, sans-serif")
118 | .attr("font-weight", "bold")
119 | .attr("font-size", this.radius/8)
120 | .attr("stroke", "#FFFFFF")
121 | .attr("stroke-width", this.radius/200)
122 | .attr("x", (d, bitIndex) => this.center(bitIndex, rowIndex)[0])
123 | .attr("y", (d, bitIndex) => this.center(bitIndex, rowIndex)[1])
124 | .attr("dx", (d, i, n) => -n[i].getBBox().width/2)
125 | .attr("dy", this.radius/8 * .4);
126 | });
127 | }
128 |
129 | makePath(bitIndex, rowIndex) {
130 | // whether or not the inner and outer edges of the rings have angled stitching
131 | const stitched = [[false, true], [true, true], [true, false], [false, false]];
132 | const stitchDepth = 0.04; // proportion of radius
133 |
134 | const innerRadius = this.radius * this.ringProportions[rowIndex][0];
135 | const outerRadius = this.radius * this.ringProportions[rowIndex][1];
136 |
137 | // arbitrary decision: the inner edge of the stitching is on the
138 | // innerRadius, and the outer edge pokes outside of it.
139 |
140 | // odd bits stitch outward going clockwise.
141 |
142 | const startAngle = this.angle(bitIndex);
143 | const endAngle = this.angle(bitIndex + 1);
144 |
145 | // define the four corner points as [angle, radius]
146 | const corners = [];
147 | if(bitIndex % 2 == 0) {
148 | corners[0] = [startAngle, innerRadius];
149 | corners[1] = [startAngle, outerRadius];
150 |
151 | if(stitched[rowIndex][1]) {
152 | corners[2] = [endAngle, outerRadius + this.radius * stitchDepth];
153 | }
154 | else {
155 | corners[2] = [endAngle, outerRadius];
156 | }
157 |
158 | if(stitched[rowIndex][0]) {
159 | corners[3] = [endAngle, innerRadius + this.radius * stitchDepth];
160 | }
161 | else {
162 | corners[3] = [endAngle, innerRadius];
163 | }
164 | }
165 | else {
166 | if(stitched[rowIndex][0]) {
167 | corners[0] = [startAngle, innerRadius + this.radius * stitchDepth];
168 | }
169 | else {
170 | corners[0] = [startAngle, innerRadius];
171 | }
172 |
173 | if(stitched[rowIndex][1]) {
174 | corners[1] = [startAngle, outerRadius + this.radius * stitchDepth];
175 | }
176 | else {
177 | corners[1] = [startAngle, outerRadius];
178 | }
179 |
180 | corners[2] = [endAngle, outerRadius];
181 | corners[3] = [endAngle, innerRadius];
182 | }
183 |
184 | const cartesianCorners = corners.map(c => d3.pointRadial(...c));
185 |
186 | let pathData = `M ${cartesianCorners[0].join(" ")}
187 | L ${cartesianCorners[1].join(" ")}`;
188 |
189 | if(stitched[rowIndex][1]) {
190 | pathData += `L ${cartesianCorners[2].join(" ")}`;
191 | }
192 | else {
193 | pathData += `A ${outerRadius * this.roundedEdgeProportion} ${outerRadius * this.roundedEdgeProportion} 0 0 1 ${cartesianCorners[2].join(" ")}`;
194 | }
195 |
196 | pathData += `L ${cartesianCorners[3]}`;
197 |
198 | if(stitched[rowIndex][0]) {
199 | pathData += "Z";
200 | }
201 | else {
202 | pathData += `A ${innerRadius * this.roundedEdgeProportion} ${innerRadius * this.roundedEdgeProportion} 0 0 0 ${cartesianCorners[0].join(" ")}`;
203 | }
204 |
205 | return pathData;
206 | }
207 |
208 | center(bitIndex, rowIndex) {
209 | const startAngle = this.angle(bitIndex);
210 | const endAngle = this.angle(bitIndex + 1);
211 | const avgAngle = (startAngle + endAngle)/2;
212 |
213 | const innerRadius = this.radius * this.ringProportions[rowIndex][0];
214 | const outerRadius = this.radius * this.ringProportions[rowIndex][1];
215 | const avgRadius = (innerRadius + outerRadius)/2;
216 |
217 | return d3.pointRadial(avgAngle, avgRadius);
218 | }
219 |
220 | size() {
221 | this.svg.attr("width", 0);
222 | const containerContainerStyle = getComputedStyle(this.container.node());
223 | const availableWidth = parseFloat(containerContainerStyle.getPropertyValue("width"))
224 | - parseFloat(containerContainerStyle.getPropertyValue("padding-left"))
225 | - parseFloat(containerContainerStyle.getPropertyValue("padding-right"));
226 |
227 | this.windowWidth = document.documentElement.clientWidth;
228 | this.windowHeight = document.documentElement.clientHeight;
229 | const availableHeight = this.windowHeight - 20;
230 |
231 | this.outerRadius = Math.min(availableWidth, availableHeight)/2;
232 | this.radius = this.outerRadius - (this.outerRadius * this.roundedEdgeProportion/2) - this.margin;
233 |
234 | this.svg
235 | .attr("width", 2 * this.outerRadius)
236 | .attr("height", 2 * this.outerRadius);
237 |
238 | this.radialContainer
239 | .attr("transform", `translate(${this.outerRadius}, ${this.outerRadius})`);
240 |
241 |
242 |
243 | }
244 | }
245 |
246 | class UIControls {
247 | constructor(containerSelector) {
248 | this.container = d3.select(containerSelector);
249 |
250 | this.textboxNames = ["Inner ring", "Ring 2", "Ring 3", "Outer ring"];
251 |
252 | this.container.html(`
253 | `);
254 |
255 | this.legend = d3.select("#legend");
256 |
257 | this.explainCheckbox = this.container.select("#explainToggle")
258 | .on("change", e => {
259 | const checked = e.currentTarget.checked;
260 | explain = checked;
261 | this.update();
262 | vis.size(); // presence of scrollbar may change
263 | vis.update();
264 | });
265 |
266 | this.textboxContainer = this.container.select(".textboxContainer");
267 |
268 | this.downloadButton = d3.select("#downloadButtonContainer").append("button")
269 | .attr("class", "btn btn-primary")
270 | .attr("id", "downloadButton")
271 | .text("Download")
272 | .on("click", () => {
273 | saveSvgAsPng(vis.svg.node(), "chute.png", { scale: 2 });
274 | });
275 | }
276 |
277 | update() {
278 | this.explainCheckbox.property("checked", explain);
279 |
280 | this.legend.style("display", explain ? null : "none");
281 |
282 | let textboxes = this.textboxContainer.selectAll("div.textbox").data(strings);
283 | const textboxesEnter = textboxes.enter().append("div").attr("class", "textbox")
284 | .html((d, i) => `
285 |
286 |
287 |
288 |
`);
289 |
290 | textboxesEnter.select("input").on("input", (e, d) => {
291 | let newString = e.currentTarget.value.toUpperCase();
292 | e.currentTarget.value = newString; // capitalize even if invalid
293 |
294 | let valid = true;
295 | try {
296 | encoder.tokenize(newString);
297 | }
298 | catch(er) {
299 | valid = false;
300 | d3.select(e.currentTarget.parentNode).select(".invalid-feedback")
301 | .text(er)
302 | .style("display", "block");
303 | }
304 | d3.select(e.currentTarget).classed("is-invalid", !valid);
305 | if(valid) {
306 | strings[e.currentTarget.dataset.index] = newString;
307 | d3.select(e.currentTarget.parentNode).select(".invalid-feedback")
308 | .style("display", null);
309 | this.update();
310 | vis.updateData();
311 | }
312 | });
313 | textboxes = textboxes.merge(textboxesEnter);
314 |
315 | textboxes.select("input")
316 | .property("value", d => d);
317 | }
318 | }
319 |
320 | class MightyThingsEncoder {
321 | constructor() {
322 | this.totalBits = 80;
323 | this.byteSize = 7;
324 | this.interByteGap = 3;
325 | this.interByteVal = false;
326 | this.padVal = true;
327 |
328 | this.letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
329 |
330 | this.inputString = "";
331 | this.outputStrings = "";
332 | }
333 |
334 | tokenize(inputString) {
335 | // turn each string into an array of characters or numbers, ignoring
336 | // spaces. if a number is < 128, return a number; otherwise characters.
337 | if(inputString !== undefined) this.inputString = inputString;
338 |
339 | const words = this.inputString.split(" ");
340 |
341 | const charArray = words.map(w => {
342 | return w.split(/(\d+)|(\D+)/g)
343 | .filter(d => d !== "" && d !== undefined)
344 | .map(d => {
345 | if(!isNaN(parseInt(d))) {
346 | const num = parseInt(d);
347 | if(num < 128) {
348 | return num;
349 | }
350 | else {
351 | throw "Numbers must be between 0 and 127";
352 | }
353 | }
354 | else {
355 | const letters = d.split("");
356 | letters.forEach(l => {
357 | if(this.letters.indexOf(l) + 1 < 1) {
358 | throw "Letters must be capital letters A–Z.";
359 | }
360 | });
361 | return letters;
362 | }
363 | });
364 | });
365 |
366 | const tokens = charArray.flat(2);
367 | if(tokens.length > 8) {
368 | throw "A ring can’t hold that much data";
369 | }
370 | else {
371 | this.tokens = tokens;
372 | }
373 |
374 | return tokens;
375 | }
376 |
377 | binEncoder(token) {
378 | let numVal;
379 | if(typeof(token) == "string") {
380 | numVal = this.letters.indexOf(token) + 1;
381 | }
382 | else {
383 | numVal = token;
384 | }
385 | const binVal = numVal.toString(2).split("");
386 |
387 | while(binVal.length < this.byteSize) {
388 | binVal.splice(0, 0, "0");
389 | }
390 |
391 | return binVal.join("");
392 | }
393 |
394 | encode(inputString) {
395 | if(inputString !== undefined) {
396 | this.inputString = inputString;
397 | }
398 | this.tokenize(this.inputString);
399 | return this.tokens.map(t => this.binEncoder(t));
400 | }
401 |
402 | encodePadded(row, inputString) {
403 | if(inputString !== undefined) {
404 | this.inputString = inputString;
405 | }
406 |
407 | let encoded = this.encode(this.inputString);
408 | encoded = encoded.map((encodedToken, byteIndex) => {
409 | return encodedToken.split("").map((bit, bitIndex) => {
410 | return {
411 | byte: byteIndex,
412 | bit: bitIndex,
413 | role: "data",
414 | token: this.tokens[byteIndex],
415 | value: bit === "1"
416 | }
417 | });
418 | });
419 |
420 | // pad between each byte
421 | for(let i = 1; encoded.length < 2 * encoder.tokens.length - 1; i += 2) {
422 | encoded.splice(i, 0, Array(this.interByteGap).fill({
423 | role: "interBytePadding",
424 | value: this.interByteVal
425 | }));
426 | }
427 |
428 | // pad before all bytes
429 | if(this.tokens.length > 0) {
430 | encoded.unshift(Array(this.interByteGap).fill({
431 | role: "preWordPadding",
432 | value: this.interByteVal
433 | }));
434 | }
435 |
436 | // pad after all bytes (if there's room)
437 | if(this.tokens.length < 8 && this.tokens.length > 0) {
438 | encoded.push(Array(this.interByteGap).fill({
439 | role: "postWordPadding",
440 | value: this.interByteVal
441 | }));
442 | }
443 |
444 | encoded = encoded.flat();
445 |
446 | // pad the end until there are 80 bits
447 | const unpaddedLength = encoded.length;
448 | this.remainingBits = this.totalBits - unpaddedLength;
449 | encoded.push(...Array(this.remainingBits).fill({
450 | role: "postDataPadding",
451 | value: this.padVal
452 | }));
453 |
454 |
455 | if(row === 0) {
456 | this.startBit = 1;
457 | }
458 |
459 | encoded = encoded.map((v, i, ar) => {
460 | let shiftedIndex = (i - this.startBit) % ar.length;
461 | shiftedIndex += shiftedIndex < 0 ? ar.length : 0;
462 | return ar[shiftedIndex];
463 | });
464 |
465 | // set up next start bit
466 | if(unpaddedLength > 0) {
467 | this.startBit = (this.startBit + (this.totalBits - this.remainingBits) - (unpaddedLength == this.totalBits ? 0: this.interByteGap)) % this.totalBits;
468 | }
469 |
470 | return encoded;
471 | }
472 |
473 | encodeAll(stringArray) {
474 | this.allOutArray = [];
475 | stringArray.forEach((s, i) => {
476 | const encoded = this.encodePadded(i, s);
477 | this.allOutArray[i] = encoded;
478 | });
479 | return this.allOutArray;
480 | }
481 | }
482 |
483 | const encoder = new MightyThingsEncoder();
484 | const vis = new ChuteVisualizer("#chuteContainer");
485 | const ui = new UIControls("#uiControls");
486 |
487 | const strings = ["DARE", "MIGHTY", "THINGS", "34 11 58 N 118 10 31 W"];
488 | let explain = false;
489 |
490 | function init() {
491 | ui.update();
492 | vis.size();
493 | vis.updateData();
494 | }
495 | init();
496 |
--------------------------------------------------------------------------------
/mightythings-spiral.mov:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Noleli/EncodeMightyThings/37faeda3510ca05d6fdb2324b29e64192655841d/mightythings-spiral.mov
--------------------------------------------------------------------------------
/mightythings.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Noleli/EncodeMightyThings/37faeda3510ca05d6fdb2324b29e64192655841d/mightythings.gif
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Noleli/EncodeMightyThings/37faeda3510ca05d6fdb2324b29e64192655841d/screenshot.png
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding-top: 16px;
3 | background-color: #c2c5dd;
4 | }
5 |
6 | #appContainer {
7 | display: grid;
8 | grid-column-gap: 16px;
9 | column-gap: 16px;
10 |
11 | grid-template-columns: 8fr 4fr;
12 | grid-template-rows: auto auto;
13 | grid-template-areas:
14 | "chute sidebar"
15 | "footer footer";
16 |
17 | --dataTrue: #b32b2b;
18 | --dataFalse: #FFFFFF;
19 | --preWordPadding: #555555;
20 | --interBytePadding: #CCCCCC;
21 | --postWordPadding: #555555;
22 | --postDataPadding: #752325;
23 | }
24 |
25 | #sidebar {
26 | display: flex;
27 | flex-direction: column;
28 | }
29 |
30 | #legend {
31 | grid-area: legend;
32 | display: flex;
33 | flex-direction: column;
34 | }
35 |
36 | @media (max-width: 768px) {
37 | #appContainer {
38 | grid-template-columns: 1fr;
39 | grid-template-areas:
40 | "header"
41 | "chute"
42 | "legend"
43 | "uiControls"
44 | "downloadButton"
45 | "footer";
46 | grid-row-gap: 16px;
47 | row-gap: 16px;
48 | }
49 |
50 | #sidebar {
51 | display: contents;
52 | }
53 |
54 | #legend {
55 | flex-direction: row;
56 | flex-wrap: wrap;
57 | /* display: grid; */
58 | /* grid-auto-columns: minmax(min-content, max-content); */
59 | /* grid-auto-flow: column;
60 | max-width: 100%; */
61 | }
62 | }
63 |
64 | #header {
65 | grid-area: header;
66 | }
67 |
68 | #chuteContainer {
69 | grid-area: chute;
70 | display: flex;
71 | justify-content: center;
72 | }
73 |
74 | #uiControls {
75 | grid-area: uiControls;
76 | }
77 |
78 | #legend .legendItem {
79 | display: flex;
80 | align-items: center;
81 | margin-bottom: 4px;
82 | margin-right: 16px;
83 | }
84 | #legend .legendItem:last-child {
85 | margin-right: 0;
86 | }
87 |
88 | #legend .itemName {
89 | white-space: nowrap;
90 | }
91 |
92 | .colorSwatch {
93 | width: 32px;
94 | height: 18px;
95 | border: 1px solid #CCCCCC;
96 | margin-right: 6px;
97 | }
98 |
99 | .colorSwatch.dataTrue {
100 | background-color: var(--dataTrue);
101 | }
102 | .colorSwatch.dataFalse {
103 | background-color: var(--dataFalse);
104 | }
105 | .colorSwatch.preWordPadding {
106 | background-color: var(--preWordPadding);
107 | }
108 | .colorSwatch.interBytePadding {
109 | background-color: var(--interBytePadding);
110 | }
111 | .colorSwatch.postDataPadding {
112 | background-color: var(--postDataPadding);
113 | }
114 |
115 | #downloadButtonContainer {
116 | grid-area: downloadButton;
117 | }
118 |
119 | #downloadButton {
120 | width: 100%;
121 | }
122 |
123 | #footer {
124 | grid-area: footer;
125 | /* margin-top: 16px; */
126 | }
127 |
--------------------------------------------------------------------------------