Fetch problem, track plays, mobile input
dkl9

dkl9 commited on 2025-209 17:33:57
Showing 4 changed files, with 173 additions and 39 deletions.

... ...
@@ -1,9 +1,13 @@
1 1
 <!DOCTYPE html>
2 2
 <html>
3 3
     <head>
4
+        <meta charset="utf-8">
4 5
         <title>Functle</title>
6
+        <link rel="stylesheet" href="/style.css">
7
+        <link rel="icon" type="image/png" href="/favicon.png">
5 8
         <style>
6
-            body { font-family: sans-serif; }
9
+            body { font-family: sans-serif; margin: 0; width: 100%; }
10
+            #mobile td { min-width: min(16vw, 5rem); height: min(16vw, 5rem); text-align: center; }
7 11
             #plot { border: 1px solid grey; flex-shrink: 0; width: min(90vw, 25em); height: min(90vw, 25em); flex-shrink: 0; }
8 12
             #grid { border-collapse: separate; border-spacing: 3px 12px; margin-top: 10px; }
9 13
             #grid td { min-width: min(13vw, 4rem); height: min(13vw, 4rem); border: 1px solid #ccc; text-align: center; vertical-align: middle; font-weight: bold; transition: transform 0.3s ease; }
... ...
@@ -20,8 +24,7 @@
20 24
                 75% { transform: translateX(-5px); }
21 25
                 100% { transform: translateX(0); }
22 26
             }
23
-            @media (min-aspect-ratio: 4/3) { .container { display: flex; } }
24
-            @media (prefers-color-scheme: dark) { body { color: white; background-color: black; } }
27
+            @media (min-aspect-ratio: 4/3) { .container { display: flex; flex-wrap: wrap; } }
25 28
         </style>
26 29
     </head>
27 30
     <body>
... ...
@@ -29,7 +32,7 @@
29 32
             <section id="instructions">
30 33
                 <h1>How to play</h1>
31 34
                 <p>Look at the green plot <var>y</var> = <var>f</var>(<var>x</var>) and guess the formula for <var>f</var>(<var>x</var>) in <a href="https://en.wikipedia.org/wiki/Reverse_Polish_notation#Explanation" target="_blank">postfix</a>.
32
-                Use one character per number or operator:</p>
35
+                Type one character per number or operator:</p>
33 36
                 <ul>
34 37
                     <li><var>x</var>, 1, or 2 (values)</li>
35 38
                     <li>q for "squared" (unary)</li>
... ...
@@ -39,6 +42,8 @@
39 42
                     <li>l for natural log</li>
40 43
                     <li>+, -, *, / for basic operations</li>
41 44
                 </ul>
45
+                <p>Or use the grid of buttons.
46
+                Inputs to - and / are still taken in the usual order, so "2 x -" is two minus <var>x</var>.</p>
42 47
                 <p>For example, write 1 / (sin² <var>x</var>) as "1 x s q /", or ln(2) <var>e</var><sup><var>x</var></sup> as "2 l x e *".</p>
43 48
                 <p>Your answer will be coloured as feedback.
44 49
                 <span class="green">Green: correct, right spot.</span>
... ...
@@ -56,9 +61,18 @@
56 61
                 </div>
57 62
                 <table id="grid"></table>
58 63
             </div>
59
-            <!-- <div class="side">
60
-                TODO: comments and questions ...
61
-            </div> -->
64
+            <div class="side">
65
+                <p>Questions on Functle are welcome.
66
+                Email them to <a href="mailto:contact@dkl9.net">contact@dkl9.net</a>.
67
+                <span style="display: none;">Avoid <a href="mailto:sucrose@dkl9.net">sucrose@dkl9.net</a>, unless you are a spambot.</span></p>
68
+                <form action="/cgi-bin/feedback" method="post">
69
+                    <p><label>Or write a comment &mdash; <input type="text" name="t" placeholder="comment here"> &mdash; and tell me what you think:
70
+                    <button type="submit" name="j" value="l">I like this</button>
71
+                    <button type="submit" name="j" value="d">I dislike this</button>
72
+                    <button type="submit" name="j" value="n">No judgment</button></p>
73
+                </form>
74
+                <p><a href="/">dkl9 home</a></p>
75
+            </div>
62 76
         </div>
63 77
         <script src="guess.js"></script>
64 78
         <script src="plot.js"></script>
... ...
@@ -7,17 +7,28 @@ const allTokens = [...allowed.terminals, ...allowed.unary, ...allowed.binary];
7 7
 const tl = 5;
8 8
 let target;
9 9
 let puzzleNum;
10
-if (true) {
10
+if (false) {
11 11
     target = generateExpr();
12 12
     puzzleNum = 0;
13 13
 } else {
14 14
     (async () => {
15 15
         const r = await fetch("https://dkl9.net/cgi-bin/fps_curr", { redirect: "follow" });
16 16
         puzzleNum = parseInt(r.url.match(/n=(\d+)/)[1]);
17
-        target = await r.text().split(" ");
17
+        target = (await r.text()).trim().split(" ");
18
+        window.dispatchEvent(new Event("targetReady"));
19
+        const score = localStorage.getItem("score").split("\n");
20
+        if (playedToday(score)) {
21
+            const [g, m] = score[0].split(" ")[2].split("/");
22
+            const note = elemText(`You already played today and `);
23
+            note.textContent += (g <= m) ? `got it in ${g} guess${g == 1 ? "" : "es"}` : `didn't figure it out`;
24
+            note.textContent += ". Come back tomorrow. Full score on clipboard.";
25
+            container.prepend(note);
26
+            navigator.clipboard.writeText(score);
27
+        }
18 28
     })();
19 29
 }
20 30
 const container = document.getElementsByClassName("container")[0];
31
+const message = document.getElementById("message");
21 32
 const boxes = {grey: "ā¬œļø", yellow: "🟨", green: "🟩"};
22 33
 const congrats = ["Incredible", "Amazing", "Great", "Nice", "You got it", "Whew"];
23 34
 const gotitButton = document.getElementById("gotit");
... ...
@@ -33,17 +44,29 @@ if (gotitButton) {
33 44
     });
34 45
 }
35 46
 
47
+function playedToday(s) {
48
+    if (!s)
49
+        s = (localStorage.getItem("score") || "").split("\n");
50
+    return s[0] && s[0].split(" ")[1] == puzzleNum;
51
+}
52
+
53
+function elemText(s, e = "p") {
54
+    const d = document.createElement(e);
55
+    d.textContent = s;
56
+    return d;
57
+}
58
+
36 59
 function submitGuess() {
37 60
     const row = grid.rows[currentRow];
38
-    document.getElementById("message").textContent = "";
61
+    message.textContent = "";
39 62
     if (currentInput.length !== tl) {
40
-        document.getElementById("message").textContent = `Use exactly ${tl} tokens.`;
63
+        message.textContent = `Use exactly ${tl} tokens.`;
41 64
         shakeRow(row);
42 65
         return;
43 66
     }
44 67
     for (const t of currentInput) {
45 68
         if (!allTokens.includes(t)) {
46
-            document.getElementById("message").textContent = "Invalid token: " + t;
69
+            message.textContent = "Invalid token: " + t;
47 70
             shakeRow(row);
48 71
             return;
49 72
         }
... ...
@@ -52,7 +75,7 @@ function submitGuess() {
52 75
     try {
53 76
         ys = xs.map(x => evalPostfix(currentInput, x));
54 77
     } catch (e) {
55
-        document.getElementById("message").textContent = "Syntax error.";
78
+        message.textContent = "Syntax error.";
56 79
         shakeRow(row);
57 80
         return;
58 81
     }
... ...
@@ -63,12 +86,13 @@ function submitGuess() {
63 86
     if (close) {
64 87
         setTimeout(() => {
65 88
             fillRow(currentInput, "green");
89
+            if (!playedToday())
66 90
                 winMessage();
67 91
         }, 300 * tl);
68 92
         return;
69 93
     }
70 94
     currentRow++;
71
-    if (currentRow >= maxGuesses) loseMessage();
95
+    if (currentRow >= maxGuesses && !playedToday()) loseMessage();
72 96
     currentInput = [];
73 97
 }
74 98
 
... ...
@@ -179,40 +203,66 @@ function hideInstructions(button) {
179 203
     button.remove();
180 204
     const heading = inst.firstElementChild;
181 205
     heading.remove();
182
-    const summary = document.createElement("summary");
183
-    summary.textContent = heading.textContent;
206
+    const summary = elemText(heading.textContent, "summary");
184 207
     const newInst = document.createElement("details");
185 208
     newInst.append(summary);
186 209
     for (const c of inst.childNodes) newInst.append(c);
187 210
     container.append(newInst);
188 211
 }
189 212
 
213
+function infix(expr) {
214
+    const stack = [];
215
+    for (const token of expr) {
216
+        if (token === '+' || token === '-') {
217
+            const b = stack.pop();
218
+            const a = stack.pop();
219
+            stack.push(`(${a} ${token} ${b})`);
220
+        } else if (token === '*' || token === '/') {
221
+            const b = stack.pop();
222
+            const a = stack.pop();
223
+            stack.push(`${a} ${token} ${b}`);
224
+        } else if (token === 'sin' || token === 'exp' || token === 'ln') {
225
+            const a = stack.pop();
226
+            stack.push(`${token}(${a})`);
227
+        } else if (token === '^2' || token === '^3') {
228
+            const a = stack.pop();
229
+            stack.push(`(${a})${token}`);
230
+        } else {
231
+            stack.push(token);
232
+        }
233
+    }
234
+    return stack[0];
235
+}
236
+
237
+function humane(f = target) {
238
+    return `${infix(f)} (input as ${f.join(" ")})`;
239
+}
240
+
190 241
 function winMessage() {
191 242
     let boast = `Functle ${puzzleNum} ${currentRow + 1}/${maxGuesses}\n`;
192 243
     for (i = 0; i <= currentRow; i++) {
193
-        r = grid.rows[i];
194
-        for (c of r.cells) {
244
+        const r = grid.rows[i];
245
+        for (const c of r.cells) {
195 246
             const l = c.classList;
196 247
             let p = "?";
197 248
             for (o in boxes) if (l.contains(o)) p = boxes[o];
198
-            boast = boast + p;
249
+            boast += p;
199 250
         }
200 251
         boast = boast + "\n";
201 252
     }
202
-    boast = boast + "https://DKL9.net/functle/";
253
+    boast += "https://DKL9.net/functle/";
254
+    localStorage.setItem("score", boast);
203 255
     navigator.clipboard.writeText(boast);
204
-    const w = document.createElement("section");
205
-    w.append(`${congrats[currentRow]}! Share your score. It's on your clipboard. `);
206
-    const b = document.createElement("button");
207
-    b.textContent = "Copy again";
256
+    const w = elemText(`${congrats[currentRow]}! The function was ${humane()}. Share your score. It's on your clipboard. `, "section");
257
+    const b = elemText("Copy again", "button");
208 258
     b.type = "button";
209 259
     b.addEventListener("click", () => navigator.clipboard.writeText(boast));
210 260
     w.append(b);
261
+    w.append(" Come back tomorrow for the next function.");
211 262
     container.prepend(w);
212 263
 }
213 264
 
214 265
 function loseMessage() {
215
-    const d = document.createElement("p");
216
-    d.textContent = `Better luck next time. The function was ${target.join(" ")}`;
217
-    container.prepend(d);
266
+    localStorage.setItem("score", `Functle ${puzzleNum} ${maxGuesses + 1}/${maxGuesses}`);
267
+    container.prepend(elemText(`Better luck next time. The function was ${humane()}`));
218 268
 }
... ...
@@ -1,5 +1,57 @@
1 1
 const keyToToken = { x:"x", 1:"1", 2:"2", q:"^2", c:"^3", s:"sin", e:"exp", l:"ln", "+":"+", "-":"-", "*":"*", "/":"/" };
2 2
 const maxGuesses = 6;
3
+const mobile = /Mobi|Android/i.test(navigator.userAgent);
4
+const buttonKeys = [["x", "1", "2", "q"], ["c", "s", "e", "l"], ["+", "-", "*", "/"]];
5
+const grid = document.getElementById("grid");
6
+
7
+if (mobile) {
8
+    const ib = document.createElement("table");
9
+    ib.id = "mobile";
10
+    for (let i = 0; i < 3; i++) {
11
+        const r = document.createElement("tr");
12
+        for (let j = 0; j < 4; j++) {
13
+            const t = keyToToken[buttonKeys[i][j]];
14
+            r.append(makeButton(t, 3.5 - 0.5 * t.length, () => {
15
+                const row = grid.rows[currentRow];
16
+                if (currentRow >= maxGuesses) {
17
+                } else if (currentInput.length < tl) {
18
+                    currentInput.push(t);
19
+                    const c = row.cells[currentInput.length - 1];
20
+                    c.style.fontSize = `${3.5 - 0.5 * t.length}em`;
21
+                    c.textContent = t;
22
+                } else {
23
+                    shakeRow(row);
24
+                }
25
+            }));
26
+        }
27
+        ib.append(r);
28
+    }
29
+    const lr = document.createElement("tr");
30
+    const dc = makeButton("delete", 3, () => {
31
+        const row = grid.rows[currentRow];
32
+        if (currentInput.length > 0) {
33
+            currentInput.pop();
34
+            row.cells[currentInput.length].textContent = "";
35
+        } else {
36
+            shakeRow(row);
37
+        }
38
+    });
39
+    dc.colSpan = buttonKeys[0].length / 2;
40
+    lr.append(dc);
41
+    const sc = makeButton("check", 3, submitGuess);
42
+    sc.colSpan = buttonKeys[0].length / 2;
43
+    lr.append(sc);
44
+    ib.append(lr);
45
+    grid.after(ib);
46
+}
47
+
48
+function makeButton(label, size, handler) {
49
+    const b = document.createElement("td");
50
+    b.textContent = label;
51
+    b.style.fontSize = `${size}em`;
52
+    b.addEventListener("click", handler);
53
+    return b;
54
+}
3 55
 
4 56
 function shakeRow(row) {
5 57
     row.classList.remove("shake");
... ...
@@ -7,7 +59,6 @@ function shakeRow(row) {
7 59
     row.classList.add("shake");
8 60
 }
9 61
 
10
-const grid = document.getElementById("grid");
11 62
 for (let i = 0; i < maxGuesses; i++) {
12 63
     const tr = document.createElement("tr");
13 64
     for (let j = 0; j < tl; j++) {
... ...
@@ -3,24 +3,33 @@ const ctx = document.getElementById("plot").getContext("2d");
3 3
 const bound = 10;
4 4
 const xs = [];
5 5
 const targetYs = [];
6
+const clampLow = 1.5;
7
+const clampHigh = 20;
8
+const guessYsList = [];
9
+const darkTheme = matchMedia('(prefers-color-scheme: dark)').matches;
10
+let yMin = -clampHigh, yMax = clampHigh;
11
+
12
+ctx.textBaseline = "middle";
13
+ctx.textAlign = "center";
14
+ctx.fillStyle = darkTheme ? "white" : "black";
15
+ctx.font = `${height * 0.15}px sans-serif`;
16
+ctx.fillText("Loading ...", width / 2, height / 2);
17
+
18
+window.addEventListener("targetReady", () => {
6 19
     for (let i = 0; i <= width; i++) {
7 20
         const x = bound * (2 * i / width - 1);
8 21
         xs.push(x);
9 22
         const yv = evalPostfix(target, x);
10 23
         targetYs.push(yv);
11 24
     }
12
-const clampLow = 1.5;
13
-const clampHigh = 20;
14
-let yMin = Math.min(...targetYs);
15
-let yMax = Math.max(...targetYs);
25
+    yMin = Math.min(...targetYs);
26
+    yMax = Math.max(...targetYs);
16 27
     if (isNaN(yMin) || yMin < -clampHigh) yMin = -clampHigh;
17 28
     else if (yMin > -clampLow) yMin = -clampLow;
18 29
     if (isNaN(yMax) || yMax > clampHigh) yMax = clampHigh;
19 30
     else if (yMax < clampLow) yMax = clampLow;
20
-const guessYsList = [];
21
-const darkTheme = matchMedia('(prefers-color-scheme: dark)').matches;
22
-
23 31
     redrawAll();
32
+});
24 33
 
25 34
 function drawAxes() {
26 35
     ctx.clearRect(0, 0, width, height);
... ...
@@ -46,12 +55,22 @@ function drawAxes() {
46 55
     ctx.stroke();
47 56
 }
48 57
 
49
-function drawCurve(ys, color, width = 4) {
58
+function drawCurve(ys, color, width = 4, threshold = 1e3) {
50 59
     ctx.beginPath();
60
+    let prevValid = false;
51 61
     for (let i = 0; i < xs.length; i++) {
52
-        const px = mapX(xs[i]), py = mapY(ys[i]);
53
-        if (i === 0) ctx.moveTo(px, py);
54
-        else ctx.lineTo(px, py);
62
+        const y = ys[i];
63
+        if (isFinite(y)) {
64
+            const px = mapX(xs[i]), py = mapY(y);
65
+            if (prevValid && Math.abs(y - ys[i - 1]) < threshold) {
66
+                ctx.lineTo(px, py);
67
+            } else {
68
+                ctx.moveTo(px, py);
69
+            }
70
+            prevValid = true;
71
+        } else {
72
+            prevValid = false;
73
+        }
55 74
     }
56 75
     ctx.lineWidth = width;
57 76
     ctx.strokeStyle = color;
58 77