├── .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 | [![Screenshot](screenshot.png)](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 | 3 | 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 |
31 |
32 |
33 | 62 | 69 |
70 |
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 | --------------------------------------------------------------------------------