@nano133/nanoterra 0.8.1 → 0.9.3
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.
- package/CHANGELOG.md +92 -0
- package/README.md +390 -6
- package/ai/SPEC.md +71 -3
- package/dist/admin/panel/mount.d.ts +3 -0
- package/dist/admin/panel/mount.js +4 -2
- package/dist/player-signin/adapters/d1.d.ts +10 -0
- package/dist/player-signin/adapters/d1.js +108 -0
- package/dist/player-signin/adapters/firestore.d.ts +45 -0
- package/dist/player-signin/adapters/firestore.js +168 -0
- package/dist/player-signin/conformance.d.ts +11 -0
- package/dist/player-signin/conformance.js +160 -0
- package/dist/player-signin/handler.d.ts +64 -0
- package/dist/player-signin/handler.js +364 -0
- package/dist/player-signin/host.d.ts +4 -0
- package/dist/player-signin/host.js +10 -0
- package/dist/player-signin/index.d.ts +9 -0
- package/dist/player-signin/index.js +15 -0
- package/dist/player-signin/memory.d.ts +15 -0
- package/dist/player-signin/memory.js +101 -0
- package/dist/player-signin/panel/index.d.ts +9 -0
- package/dist/player-signin/panel/index.js +8 -0
- package/dist/player-signin/panel/memo.d.ts +12 -0
- package/dist/player-signin/panel/memo.js +41 -0
- package/dist/player-signin/panel/mount.d.ts +21 -0
- package/dist/player-signin/panel/mount.js +107 -0
- package/dist/player-signin/panel/view.d.ts +48 -0
- package/dist/player-signin/panel/view.js +363 -0
- package/dist/player-signin/panel/words.d.ts +103 -0
- package/dist/player-signin/panel/words.js +111 -0
- package/dist/player-signin/scheme-check.d.ts +3 -0
- package/dist/player-signin/scheme-check.js +14 -0
- package/dist/player-signin/scheme.d.ts +13 -0
- package/dist/player-signin/scheme.js +28 -0
- package/dist/player-signin/types.d.ts +116 -0
- package/dist/player-signin/types.js +33 -0
- package/dist/shop/adapters/d1.d.ts +25 -0
- package/dist/shop/adapters/d1.js +200 -0
- package/dist/shop/adapters/firestore.d.ts +53 -0
- package/dist/shop/adapters/firestore.js +245 -0
- package/dist/shop/address.d.ts +14 -0
- package/dist/shop/address.js +104 -0
- package/dist/shop/amount.d.ts +26 -0
- package/dist/shop/amount.js +59 -0
- package/dist/shop/conformance.d.ts +13 -0
- package/dist/shop/conformance.js +203 -0
- package/dist/shop/handler.d.ts +76 -0
- package/dist/shop/handler.js +156 -0
- package/dist/shop/index.d.ts +19 -0
- package/dist/shop/index.js +21 -0
- package/dist/shop/match.d.ts +23 -0
- package/dist/shop/match.js +58 -0
- package/dist/shop/memory.d.ts +13 -0
- package/dist/shop/memory.js +106 -0
- package/dist/shop/nodes.d.ts +47 -0
- package/dist/shop/nodes.js +106 -0
- package/dist/shop/orders.d.ts +47 -0
- package/dist/shop/orders.js +82 -0
- package/dist/shop/panel/index.d.ts +9 -0
- package/dist/shop/panel/index.js +14 -0
- package/dist/shop/panel/looks.d.ts +33 -0
- package/dist/shop/panel/looks.js +61 -0
- package/dist/shop/panel/mount.d.ts +68 -0
- package/dist/shop/panel/mount.js +221 -0
- package/dist/shop/panel/pay.d.ts +27 -0
- package/dist/shop/panel/pay.js +128 -0
- package/dist/shop/panel/qr.d.ts +9 -0
- package/dist/shop/panel/qr.js +281 -0
- package/dist/shop/panel/words.d.ts +89 -0
- package/dist/shop/panel/words.js +79 -0
- package/dist/shop/price.d.ts +23 -0
- package/dist/shop/price.js +43 -0
- package/dist/shop/scan.d.ts +24 -0
- package/dist/shop/scan.js +80 -0
- package/dist/shop/security.d.ts +10 -0
- package/dist/shop/security.js +56 -0
- package/dist/shop/store.d.ts +101 -0
- package/dist/shop/store.js +4 -0
- package/dist/shop/worker.d.ts +43 -0
- package/dist/shop/worker.js +76 -0
- package/package.json +21 -6
- package/src/admin/panel/mount.ts +7 -2
- package/src/player-signin/adapters/d1.sql +45 -0
- package/src/player-signin/adapters/d1.ts +120 -0
- package/src/player-signin/adapters/firestore.ts +194 -0
- package/src/player-signin/conformance.ts +184 -0
- package/src/player-signin/handler.ts +404 -0
- package/src/player-signin/host.ts +12 -0
- package/src/player-signin/index.ts +20 -0
- package/src/player-signin/memory.ts +94 -0
- package/src/player-signin/panel/index.ts +13 -0
- package/src/player-signin/panel/memo.ts +49 -0
- package/src/player-signin/panel/mount.ts +129 -0
- package/src/player-signin/panel/style.css +478 -0
- package/src/player-signin/panel/view.ts +433 -0
- package/src/player-signin/panel/words.ts +210 -0
- package/src/player-signin/scheme-check.ts +14 -0
- package/src/player-signin/scheme.ts +32 -0
- package/src/player-signin/types.ts +103 -0
- package/src/shop/adapters/d1.sql +52 -0
- package/src/shop/adapters/d1.ts +245 -0
- package/src/shop/adapters/firestore.ts +281 -0
- package/src/shop/address.ts +102 -0
- package/src/shop/amount.ts +66 -0
- package/src/shop/conformance.ts +232 -0
- package/src/shop/handler.ts +202 -0
- package/src/shop/index.ts +30 -0
- package/src/shop/match.ts +63 -0
- package/src/shop/memory.ts +102 -0
- package/src/shop/nodes.ts +115 -0
- package/src/shop/orders.ts +93 -0
- package/src/shop/panel/index.ts +19 -0
- package/src/shop/panel/looks.ts +106 -0
- package/src/shop/panel/mount.ts +280 -0
- package/src/shop/panel/pay.ts +192 -0
- package/src/shop/panel/qr.ts +256 -0
- package/src/shop/panel/style.css +538 -0
- package/src/shop/panel/words.ts +172 -0
- package/src/shop/price.ts +52 -0
- package/src/shop/scan.ts +90 -0
- package/src/shop/security.ts +50 -0
- package/src/shop/store.ts +99 -0
- package/src/shop/worker.ts +107 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The pay sheet for one order (the game starter's src/client/ui/pay.ts). It shows the exact amount, a QR code and a "nano:" link for the wallet, and asks
|
|
2
|
+
// the game every 5 seconds whether the payment arrived, for 2 minutes per check.
|
|
3
|
+
//
|
|
4
|
+
// TEST MODE: the server sends no address and no link, and this sheet shows a large "TEST MODE: DO NOT PAY" stamp where the QR code would be, with the
|
|
5
|
+
// wallet key off. Never change that: a payment to an address that nobody holds the key for is lost for good. The sheet decides it from the ORDER (its
|
|
6
|
+
// `test`, a missing `uri` or a missing `address`), never from a setting of the page. The stamp's words are not among the game's words (./words.ts).
|
|
7
|
+
|
|
8
|
+
import { h } from "../../admin/panel/dom.js";
|
|
9
|
+
import { usdText } from "../../looks/look.js";
|
|
10
|
+
import type { PayView } from "../handler.js";
|
|
11
|
+
import { qrSvg } from "./qr.js";
|
|
12
|
+
import { priceLine, shopWords, type ShopWords } from "./words.js";
|
|
13
|
+
|
|
14
|
+
export const PAY_TIMING = { pollMs: 5000, checkForMs: 2 * 60_000 } as const;
|
|
15
|
+
|
|
16
|
+
export type PayOptions = {
|
|
17
|
+
/** The look's name, for the line at the top. */
|
|
18
|
+
lookName: string;
|
|
19
|
+
/** POST {base}/{id}/check: the order again, after a payment scan. */
|
|
20
|
+
check(id: string): Promise<PayView>;
|
|
21
|
+
/** The order is paid: the look is the player's now. */
|
|
22
|
+
onPaid(order: PayView): void;
|
|
23
|
+
/** Words of a failed check (an ApiError's message), or null for "No connection". */
|
|
24
|
+
errorText?(e: unknown): string | null;
|
|
25
|
+
/** The time now in ms (a test gives its own). */
|
|
26
|
+
now?: () => number;
|
|
27
|
+
/** The game's words (./words.ts): the keys it leaves out keep the defaults. */
|
|
28
|
+
words?: Partial<ShopWords>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PayPanel = { node: HTMLElement; stop(): void };
|
|
32
|
+
|
|
33
|
+
/** True when this order must show the test stamp: the server said test mode, or it sent no address or no link. */
|
|
34
|
+
export const isTestOrder = (o: Pick<PayView, "test" | "uri" | "address">): boolean => o.test || !o.uri || !o.address;
|
|
35
|
+
|
|
36
|
+
export function payPanel(start: PayView, options: PayOptions): PayPanel {
|
|
37
|
+
let order = start;
|
|
38
|
+
const now = options.now ?? (() => Date.now());
|
|
39
|
+
let stopped = false;
|
|
40
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
41
|
+
let clockTimer: ReturnType<typeof setInterval> | undefined;
|
|
42
|
+
let checkingUntil = 0;
|
|
43
|
+
const w = shopWords(options.words);
|
|
44
|
+
const test = isTestOrder(order);
|
|
45
|
+
|
|
46
|
+
/** The order's state. In test mode nothing is checked, so it has nothing to say and takes no room. */
|
|
47
|
+
const status = h("p", { class: "n133s-status", role: "status", "aria-live": "polite", hidden: test });
|
|
48
|
+
const again = h("button", { class: "n133s-btn n133s-wide", type: "button", hidden: true }, w.checkAgain);
|
|
49
|
+
/** The time left, live only: in test mode nothing is paid, so nothing waits for the end. */
|
|
50
|
+
const ends = h("p", { class: "n133s-fine n133s-time-left", hidden: test });
|
|
51
|
+
const amount = h(
|
|
52
|
+
"div",
|
|
53
|
+
{ class: "n133s-amount" },
|
|
54
|
+
test ? null : h("span", { class: "n133s-exact" }, w.sendExactly),
|
|
55
|
+
h("strong", {}, w.amount(order.xno)),
|
|
56
|
+
h("span", {}, w.usd(usdText(Number(order.usd)))),
|
|
57
|
+
);
|
|
58
|
+
const copyKey = (value: string) => {
|
|
59
|
+
const key = h("button", { class: "n133s-btn n133s-small", type: "button" }, w.copy);
|
|
60
|
+
key.addEventListener("click", async () => {
|
|
61
|
+
try {
|
|
62
|
+
await navigator.clipboard.writeText(value);
|
|
63
|
+
key.textContent = w.copied;
|
|
64
|
+
} catch {
|
|
65
|
+
key.textContent = w.copyFailed;
|
|
66
|
+
}
|
|
67
|
+
setTimeout(() => (key.textContent = w.copy), 1500);
|
|
68
|
+
});
|
|
69
|
+
return key;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
let payArea: HTMLElement;
|
|
73
|
+
/** The main key (the wallet link; off in test mode): it stands in a foot that stays in view, so a phone on its side never hides it. */
|
|
74
|
+
let wallet: HTMLElement;
|
|
75
|
+
if (test) {
|
|
76
|
+
payArea = h(
|
|
77
|
+
"div",
|
|
78
|
+
{ class: "n133s-test-pay" },
|
|
79
|
+
h("div", { class: "n133s-qr n133s-qr-blocked", "aria-hidden": "true" }, h("div", { class: "n133s-qr-fake" })),
|
|
80
|
+
h("div", { class: "n133s-stamp", role: "alert" }, h("span", {}, "TEST MODE:"), " ", h("span", {}, "DO NOT PAY")),
|
|
81
|
+
h("p", { class: "n133s-fine" }, w.testNote),
|
|
82
|
+
);
|
|
83
|
+
wallet = h("button", { class: "n133s-btn n133s-primary n133s-wide", type: "button", disabled: true }, w.walletOff);
|
|
84
|
+
} else {
|
|
85
|
+
payArea = h(
|
|
86
|
+
"div",
|
|
87
|
+
{ class: "n133s-live-pay" },
|
|
88
|
+
h("div", { class: "n133s-qr" }, qrSvg(order.uri!, w.qrLabel(order.xno))),
|
|
89
|
+
h(
|
|
90
|
+
"details",
|
|
91
|
+
{ class: "n133s-by-hand" },
|
|
92
|
+
h("summary", {}, w.byHand),
|
|
93
|
+
h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, w.amountLabel), h("code", {}, w.amount(order.xno)), copyKey(order.xno)),
|
|
94
|
+
h("div", { class: "n133s-row" }, h("span", { class: "n133s-label" }, w.toLabel), h("code", { class: "n133s-address" }, order.address!), copyKey(order.address!)),
|
|
95
|
+
h("p", { class: "n133s-fine" }, w.byHandNote),
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
wallet = h("a", { class: "n133s-btn n133s-primary n133s-wide", href: order.uri! }, w.wallet);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const node = h(
|
|
102
|
+
"div",
|
|
103
|
+
{ class: `n133s-pay${test ? " n133s-is-test" : ""}` },
|
|
104
|
+
h(
|
|
105
|
+
"div",
|
|
106
|
+
{ class: "n133s-pay-main" },
|
|
107
|
+
h("p", { class: "n133s-pay-what" }, w.lookLine(options.lookName)),
|
|
108
|
+
amount,
|
|
109
|
+
payArea,
|
|
110
|
+
),
|
|
111
|
+
h(
|
|
112
|
+
"div",
|
|
113
|
+
{ class: "n133s-pay-side" },
|
|
114
|
+
ends,
|
|
115
|
+
status,
|
|
116
|
+
again,
|
|
117
|
+
h("p", { class: "n133s-fine n133s-prices" }, priceLine(w, order.prices)),
|
|
118
|
+
h("div", { class: "n133s-foot" }, wallet),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const stop = () => {
|
|
123
|
+
stopped = true;
|
|
124
|
+
if (timer) clearTimeout(timer);
|
|
125
|
+
if (clockTimer) clearInterval(clockTimer);
|
|
126
|
+
clockTimer = undefined;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const tick = () => {
|
|
130
|
+
const ms = Math.max(0, order.expiresAt - now());
|
|
131
|
+
const m = Math.floor(ms / 60_000);
|
|
132
|
+
const s = Math.floor((ms % 60_000) / 1000);
|
|
133
|
+
ends.textContent = w.endsIn(`${m}:${String(s).padStart(2, "0")}`);
|
|
134
|
+
};
|
|
135
|
+
tick();
|
|
136
|
+
clockTimer = setInterval(tick, 1000);
|
|
137
|
+
|
|
138
|
+
function show(o: PayView): boolean {
|
|
139
|
+
order = o;
|
|
140
|
+
if (o.status === "paid") {
|
|
141
|
+
stop();
|
|
142
|
+
payArea.replaceChildren(h("div", { class: "n133s-paid" }, h("strong", {}, w.paid), h("p", {}, w.paidLine(options.lookName))));
|
|
143
|
+
wallet.hidden = true;
|
|
144
|
+
status.textContent = "";
|
|
145
|
+
again.hidden = true;
|
|
146
|
+
options.onPaid(o);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (o.status === "paid_late") {
|
|
150
|
+
stop();
|
|
151
|
+
status.textContent = w.paidLate;
|
|
152
|
+
again.hidden = true;
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
if (o.status === "expired") {
|
|
156
|
+
stop();
|
|
157
|
+
status.textContent = w.expired;
|
|
158
|
+
again.hidden = false;
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function poll() {
|
|
165
|
+
if (stopped) return;
|
|
166
|
+
try {
|
|
167
|
+
const o = await options.check(order.id);
|
|
168
|
+
if (show(o)) return;
|
|
169
|
+
if (o.status === "open") status.textContent = test ? "" : w.waiting;
|
|
170
|
+
} catch (e) {
|
|
171
|
+
status.textContent = options.errorText?.(e) ?? w.noConnection;
|
|
172
|
+
}
|
|
173
|
+
if (now() < checkingUntil) timer = setTimeout(poll, PAY_TIMING.pollMs);
|
|
174
|
+
else if (!stopped) {
|
|
175
|
+
status.textContent = w.stillWaiting;
|
|
176
|
+
again.hidden = false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function startChecking() {
|
|
181
|
+
stopped = false;
|
|
182
|
+
again.hidden = true;
|
|
183
|
+
checkingUntil = now() + PAY_TIMING.checkForMs;
|
|
184
|
+
clockTimer ??= setInterval(tick, 1000);
|
|
185
|
+
void poll();
|
|
186
|
+
}
|
|
187
|
+
again.addEventListener("click", startChecking);
|
|
188
|
+
// Test mode makes no checks: nothing can be paid, so nothing is waited for.
|
|
189
|
+
if (!test) startChecking();
|
|
190
|
+
|
|
191
|
+
return { node, stop };
|
|
192
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// A QR code for the pay sheet, made in the browser: no outside service sees the payment request, and the package needs no QR library. Byte mode, error
|
|
2
|
+
// correction level M (as the game starter's qrcode-generator used), versions 1 to 10: up to 213 bytes, and a nano: link with a raw amount is at most 117.
|
|
3
|
+
// The steps are ISO/IEC 18004's (and Project Nayuki's reference code): the data and its Reed-Solomon codes, the fixed patterns, the zig-zag placement, and
|
|
4
|
+
// the mask with the lowest penalty. `qrModules` has no DOM; `qrSvg` draws it as one SVG path (crisp at any size, fine under a strict CSP).
|
|
5
|
+
|
|
6
|
+
/** For each version 1 to 10 at level M: the EC codewords per block, and the data codewords of each block. */
|
|
7
|
+
const BLOCKS: readonly [number, readonly number[]][] = [
|
|
8
|
+
[10, [16]],
|
|
9
|
+
[16, [28]],
|
|
10
|
+
[26, [44]],
|
|
11
|
+
[18, [32, 32]],
|
|
12
|
+
[24, [43, 43]],
|
|
13
|
+
[16, [27, 27, 27, 27]],
|
|
14
|
+
[18, [31, 31, 31, 31]],
|
|
15
|
+
[22, [38, 38, 39, 39]],
|
|
16
|
+
[22, [36, 36, 36, 37, 37]],
|
|
17
|
+
[26, [43, 43, 43, 43, 44]],
|
|
18
|
+
];
|
|
19
|
+
const ALIGN: readonly (readonly number[])[] = [[], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50]];
|
|
20
|
+
|
|
21
|
+
const gfMul = (x: number, y: number): number => {
|
|
22
|
+
let z = 0;
|
|
23
|
+
for (let i = 7; i >= 0; i--) {
|
|
24
|
+
z = (z << 1) ^ ((z >>> 7) * 0x11d);
|
|
25
|
+
z ^= ((y >>> i) & 1) * x;
|
|
26
|
+
}
|
|
27
|
+
return z & 0xff;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function rsDivisor(degree: number): number[] {
|
|
31
|
+
const out = new Array<number>(degree).fill(0);
|
|
32
|
+
out[degree - 1] = 1;
|
|
33
|
+
let root = 1;
|
|
34
|
+
for (let i = 0; i < degree; i++) {
|
|
35
|
+
for (let j = 0; j < degree; j++) {
|
|
36
|
+
out[j] = gfMul(out[j], root);
|
|
37
|
+
if (j + 1 < degree) out[j] ^= out[j + 1];
|
|
38
|
+
}
|
|
39
|
+
root = gfMul(root, 0x02);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rsRemainder(data: readonly number[], divisor: readonly number[]): number[] {
|
|
45
|
+
const out = new Array<number>(divisor.length).fill(0);
|
|
46
|
+
for (const b of data) {
|
|
47
|
+
const factor = b ^ (out.shift() as number);
|
|
48
|
+
out.push(0);
|
|
49
|
+
divisor.forEach((c, i) => (out[i] ^= gfMul(c, factor)));
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The codewords (data and EC, interleaved) of `bytes` in `version`. */
|
|
55
|
+
function codewords(bytes: Uint8Array, version: number): number[] {
|
|
56
|
+
const [ecLen, blocks] = BLOCKS[version - 1];
|
|
57
|
+
const capacity = blocks.reduce((a, b) => a + b, 0);
|
|
58
|
+
const bits: number[] = [];
|
|
59
|
+
const put = (value: number, n: number) => {
|
|
60
|
+
for (let i = n - 1; i >= 0; i--) bits.push((value >>> i) & 1);
|
|
61
|
+
};
|
|
62
|
+
put(0b0100, 4);
|
|
63
|
+
put(bytes.length, version < 10 ? 8 : 16);
|
|
64
|
+
for (const b of bytes) put(b, 8);
|
|
65
|
+
put(0, Math.min(4, capacity * 8 - bits.length));
|
|
66
|
+
while (bits.length % 8) bits.push(0);
|
|
67
|
+
const data: number[] = [];
|
|
68
|
+
for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
|
|
69
|
+
for (let pad = 0xec; data.length < capacity; pad ^= 0xec ^ 0x11) data.push(pad);
|
|
70
|
+
const divisor = rsDivisor(ecLen);
|
|
71
|
+
const parts: number[][] = [];
|
|
72
|
+
let at = 0;
|
|
73
|
+
for (const n of blocks) {
|
|
74
|
+
parts.push(data.slice(at, at + n));
|
|
75
|
+
at += n;
|
|
76
|
+
}
|
|
77
|
+
const ecs = parts.map((p) => rsRemainder(p, divisor));
|
|
78
|
+
const out: number[] = [];
|
|
79
|
+
const longest = Math.max(...blocks);
|
|
80
|
+
for (let i = 0; i < longest; i++) for (const p of parts) if (i < p.length) out.push(p[i]);
|
|
81
|
+
for (let i = 0; i < ecLen; i++) for (const e of ecs) out.push(e[i]);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const MASKS: readonly ((x: number, y: number) => boolean)[] = [
|
|
86
|
+
(x, y) => (x + y) % 2 === 0,
|
|
87
|
+
(_x, y) => y % 2 === 0,
|
|
88
|
+
(x) => x % 3 === 0,
|
|
89
|
+
(x, y) => (x + y) % 3 === 0,
|
|
90
|
+
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
|
|
91
|
+
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
|
92
|
+
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
93
|
+
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** The penalty of a finished matrix (the four rules of the standard). */
|
|
97
|
+
function penalty(m: boolean[][]): number {
|
|
98
|
+
const n = m.length;
|
|
99
|
+
let score = 0;
|
|
100
|
+
const lines: boolean[][] = [];
|
|
101
|
+
for (let i = 0; i < n; i++) {
|
|
102
|
+
lines.push(m[i]);
|
|
103
|
+
lines.push(m.map((row) => row[i]));
|
|
104
|
+
}
|
|
105
|
+
const finder = [true, false, true, true, true, false, true];
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
let run = 1;
|
|
108
|
+
for (let i = 1; i <= n; i++) {
|
|
109
|
+
if (i < n && line[i] === line[i - 1]) run++;
|
|
110
|
+
else {
|
|
111
|
+
if (run >= 5) score += 3 + (run - 5);
|
|
112
|
+
run = 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (let i = 0; i + 7 <= n; i++) {
|
|
116
|
+
if (!finder.every((v, k) => line[i + k] === v)) continue;
|
|
117
|
+
const lightBefore = i >= 4 && [1, 2, 3, 4].every((k) => !line[i - k]);
|
|
118
|
+
const lightAfter = i + 11 <= n && [7, 8, 9, 10].every((k) => !line[i + k]);
|
|
119
|
+
if (lightBefore) score += 40;
|
|
120
|
+
if (lightAfter) score += 40;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (let y = 0; y + 1 < n; y++) for (let x = 0; x + 1 < n; x++) if (m[y][x] === m[y][x + 1] && m[y][x] === m[y + 1][x] && m[y][x] === m[y + 1][x + 1]) score += 3;
|
|
124
|
+
const dark = m.reduce((a, row) => a + row.filter(Boolean).length, 0);
|
|
125
|
+
score += Math.floor(Math.abs((dark * 100) / (n * n) - 50) / 5) * 10;
|
|
126
|
+
return score;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The smallest version that holds `length` bytes at level M, or 0. */
|
|
130
|
+
export const qrVersionFor = (length: number): number => BLOCKS.findIndex(([, b], i) => 4 + (i + 1 < 10 ? 8 : 16) + 8 * length <= 8 * b.reduce((x, y) => x + y, 0)) + 1;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The modules of the QR code of `text` (UTF-8): `true` is dark, `[row][column]`, without the quiet zone. `mask` forces a mask (0 to 7); by default the
|
|
134
|
+
* one with the lowest penalty. Throws when the text is longer than version 10 holds (213 bytes).
|
|
135
|
+
*/
|
|
136
|
+
export function qrModules(text: string, mask?: number): boolean[][] {
|
|
137
|
+
const bytes = new TextEncoder().encode(text);
|
|
138
|
+
const version = qrVersionFor(bytes.length);
|
|
139
|
+
if (version < 1) throw new Error("qrModules: the text is longer than this QR code holds (213 bytes)");
|
|
140
|
+
const size = version * 4 + 17;
|
|
141
|
+
const m: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
|
|
142
|
+
const fixed: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
|
|
143
|
+
const set = (x: number, y: number, dark: boolean) => {
|
|
144
|
+
m[y][x] = dark;
|
|
145
|
+
fixed[y][x] = true;
|
|
146
|
+
};
|
|
147
|
+
for (let i = 0; i < size; i++) {
|
|
148
|
+
set(6, i, i % 2 === 0);
|
|
149
|
+
set(i, 6, i % 2 === 0);
|
|
150
|
+
}
|
|
151
|
+
for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
|
|
152
|
+
for (let dy = -4; dy <= 4; dy++)
|
|
153
|
+
for (let dx = -4; dx <= 4; dx++) {
|
|
154
|
+
const x = cx + dx;
|
|
155
|
+
const y = cy + dy;
|
|
156
|
+
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
|
157
|
+
if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, d !== 2 && d !== 4);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const pos = ALIGN[version - 1];
|
|
161
|
+
for (let i = 0; i < pos.length; i++)
|
|
162
|
+
for (let j = 0; j < pos.length; j++) {
|
|
163
|
+
const corner = (i === 0 && j === 0) || (i === 0 && j === pos.length - 1) || (i === pos.length - 1 && j === 0);
|
|
164
|
+
if (corner) continue;
|
|
165
|
+
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(pos[i] + dx, pos[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
166
|
+
}
|
|
167
|
+
const drawFormat = (maskNo: number) => {
|
|
168
|
+
const data = (0 << 3) | maskNo; // level M is 00
|
|
169
|
+
let rem = data;
|
|
170
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
171
|
+
const bits = ((data << 10) | rem) ^ 0x5412;
|
|
172
|
+
const bit = (i: number) => ((bits >>> i) & 1) === 1;
|
|
173
|
+
for (let i = 0; i <= 5; i++) set(8, i, bit(i));
|
|
174
|
+
set(8, 7, bit(6));
|
|
175
|
+
set(8, 8, bit(7));
|
|
176
|
+
set(7, 8, bit(8));
|
|
177
|
+
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
|
|
178
|
+
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
|
|
179
|
+
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
|
|
180
|
+
set(8, size - 8, true);
|
|
181
|
+
};
|
|
182
|
+
drawFormat(0);
|
|
183
|
+
if (version >= 7) {
|
|
184
|
+
let rem = version;
|
|
185
|
+
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
|
|
186
|
+
const bits = (version << 12) | rem;
|
|
187
|
+
for (let i = 0; i < 18; i++) {
|
|
188
|
+
const dark = ((bits >>> i) & 1) === 1;
|
|
189
|
+
const a = size - 11 + (i % 3);
|
|
190
|
+
const b = Math.floor(i / 3);
|
|
191
|
+
set(a, b, dark);
|
|
192
|
+
set(b, a, dark);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const words = codewords(bytes, version);
|
|
196
|
+
let i = 0;
|
|
197
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
198
|
+
if (right === 6) right = 5;
|
|
199
|
+
for (let vert = 0; vert < size; vert++)
|
|
200
|
+
for (let j = 0; j < 2; j++) {
|
|
201
|
+
const x = right - j;
|
|
202
|
+
const upward = ((right + 1) & 2) === 0;
|
|
203
|
+
const y = upward ? size - 1 - vert : vert;
|
|
204
|
+
if (!fixed[y][x] && i < words.length * 8) {
|
|
205
|
+
m[y][x] = ((words[i >>> 3] >>> (7 - (i & 7))) & 1) === 1;
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const masked = (k: number) => {
|
|
211
|
+
const out = m.map((row) => row.slice());
|
|
212
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fixed[y][x] && MASKS[k](x, y)) out[y][x] = !out[y][x];
|
|
213
|
+
return out;
|
|
214
|
+
};
|
|
215
|
+
const pick = (k: number) => {
|
|
216
|
+
drawFormat(k);
|
|
217
|
+
return masked(k);
|
|
218
|
+
};
|
|
219
|
+
if (mask !== undefined) {
|
|
220
|
+
if (!Number.isInteger(mask) || mask < 0 || mask > 7) throw new Error("qrModules: a mask is 0 to 7");
|
|
221
|
+
return pick(mask);
|
|
222
|
+
}
|
|
223
|
+
let best = 0;
|
|
224
|
+
let bestScore = Infinity;
|
|
225
|
+
for (let k = 0; k < 8; k++) {
|
|
226
|
+
const s = penalty(pick(k));
|
|
227
|
+
if (s < bestScore) (best = k), (bestScore = s);
|
|
228
|
+
}
|
|
229
|
+
return pick(best);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
233
|
+
|
|
234
|
+
/** The QR code of `text` as an SVG element: dark modules on white, with a quiet zone of 4 modules. `label` names it for a screen reader. */
|
|
235
|
+
export function qrSvg(text: string, label: string): SVGSVGElement {
|
|
236
|
+
const m = qrModules(text);
|
|
237
|
+
const quiet = 4;
|
|
238
|
+
const n = m.length + quiet * 2;
|
|
239
|
+
const svg = document.createElementNS(SVG, "svg");
|
|
240
|
+
svg.setAttribute("viewBox", `0 0 ${n} ${n}`);
|
|
241
|
+
svg.setAttribute("class", "n133s-qr-code");
|
|
242
|
+
svg.setAttribute("role", "img");
|
|
243
|
+
svg.setAttribute("aria-label", label);
|
|
244
|
+
svg.setAttribute("shape-rendering", "crispEdges");
|
|
245
|
+
const back = document.createElementNS(SVG, "rect");
|
|
246
|
+
back.setAttribute("width", String(n));
|
|
247
|
+
back.setAttribute("height", String(n));
|
|
248
|
+
back.setAttribute("fill", "#ffffff");
|
|
249
|
+
let d = "";
|
|
250
|
+
m.forEach((row, y) => row.forEach((dark, x) => (d += dark ? `M${x + quiet} ${y + quiet}h1v1h-1z` : "")));
|
|
251
|
+
const path = document.createElementNS(SVG, "path");
|
|
252
|
+
path.setAttribute("d", d);
|
|
253
|
+
path.setAttribute("fill", "#111318");
|
|
254
|
+
svg.append(back, path);
|
|
255
|
+
return svg;
|
|
256
|
+
}
|