@7365admin1/layer-common 4.98.0 → 4.99.1
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 +16 -4
- package/components/InventoryItemDetail.vue +2 -2
- package/components/InventoryLinesDialog.vue +2 -2
- package/components/InventoryMyStockTab.vue +3 -3
- package/components/InventoryUsageDialog.vue +2 -2
- package/components/InventoryWriteOffDialog.vue +2 -2
- package/components/SiteProviderChange.vue +16 -3
- package/components/VehicleQrStickerDialog.vue +9 -4
- package/package.json +1 -1
- package/utils/inventory-ack.ts +7 -3
- package/utils/vehicle-qr-design.ts +96 -46
- package/utils/vehicle-qr-sticker.ts +11 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.99.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1e71b4c: Provider change dialog: only the newest preview is shown (typing a date could leave an older "Straight away" preview on screen), and "On a date" without a date asks for the date. Inventory quantity boxes show "1 pc" / "2 pcs" (the suffix follows the number).
|
|
8
|
+
|
|
9
|
+
## 4.99.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- cf10e71: Vehicle QR stickers: shaped codes no longer show a square. The pattern now fills the whole shape right up to the real code, on the same grid and in the same dot style and density, so the shape reads as one continuous code; only the three corner eyes keep a small paper ring (scanners find the code by them). The plate number moved out of the shape onto a paper tab under it, and a new "Text" choice can leave the text off (the shape alone is the sticker). Sizes, cutting layout and the print-at-100% info follow the new geometry. Every shape x size x style x colour still decodes with ZXing, jsQR and ZBar under the camera conditions plus simulated phone photos.
|
|
14
|
+
|
|
3
15
|
## 4.98.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -2963,11 +2975,11 @@
|
|
|
2963
2975
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
2964
2976
|
own rule from two endpoints the console already calls, unprojected:
|
|
2965
2977
|
|
|
2966
|
-
|
|
2967
|
-
|
|
2978
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
2979
|
+
GET /api/roles/id/:role that membership's role document
|
|
2968
2980
|
|
|
2969
|
-
|
|
2970
|
-
|
|
2981
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
2982
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
2971
2983
|
|
|
2972
2984
|
`role.default` is the marker because it is the only property of a platform
|
|
2973
2985
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
<AppCard class="pa-4">
|
|
41
41
|
<div class="text-subtitle-2 mb-2">How many are on the shelf right now?</div>
|
|
42
42
|
<div class="d-flex flex-wrap ga-2">
|
|
43
|
-
<v-text-field v-model.number="counted" type="number" min="0" label="Counted" :suffix="item.unit" density="comfortable" style="max-width: 160px" :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0) || 'Enter a whole number']" />
|
|
43
|
+
<v-text-field v-model.number="counted" type="number" min="0" label="Counted" :suffix="unitWord(counted, item.unit)" density="comfortable" style="max-width: 160px" :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0) || 'Enter a whole number']" />
|
|
44
44
|
<v-text-field v-model="reason" label="Reason for the difference" density="comfortable" class="flex-grow-1" :rules="[requiredRule]" placeholder="e.g. 2 broken, found extra box" />
|
|
45
45
|
<v-btn class="text-none screen-btn-primary" variant="flat" :loading="saving" @click="saveCount">Save count</v-btn>
|
|
46
46
|
</div>
|
|
@@ -93,7 +93,7 @@ import useUtils from "../composables/useUtils";
|
|
|
93
93
|
import { inventoryItemMeta } from "../utils/inventory-store-label";
|
|
94
94
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
95
95
|
import { receiptRefText } from "../utils/inventory-receiving";
|
|
96
|
-
import { availableText, inventoryStepsLive, lowStockAlertText, qtyUnit, writeOffLabel } from "../utils/inventory-ack";
|
|
96
|
+
import { availableText, inventoryStepsLive, lowStockAlertText, qtyUnit, unitWord, writeOffLabel } from "../utils/inventory-ack";
|
|
97
97
|
|
|
98
98
|
const show = defineModel<boolean>({ default: false });
|
|
99
99
|
const props = defineProps({
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
density="comfortable"
|
|
72
72
|
style="max-width: 110px"
|
|
73
73
|
:rules="[qtyRule(line)]"
|
|
74
|
-
:suffix="unitOf(line.item)"
|
|
74
|
+
:suffix="unitWord(line.qty, unitOf(line.item))"
|
|
75
75
|
/>
|
|
76
76
|
<v-text-field
|
|
77
77
|
v-if="mode === 'receive' && receivingType !== 'CASH'"
|
|
@@ -171,7 +171,7 @@
|
|
|
171
171
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
172
172
|
import useUtils from "../composables/useUtils";
|
|
173
173
|
import { normaliseUsedBy, usedByChips, usedByText } from "../utils/inventory-used-by";
|
|
174
|
-
import { availableQty, availableText as availableTextOf, qtyUnit } from "../utils/inventory-ack";
|
|
174
|
+
import { availableQty, availableText as availableTextOf, qtyUnit, unitWord } from "../utils/inventory-ack";
|
|
175
175
|
import { RECEIVING_TYPES, receivingFields, receivingProblem, type TReceivingType } from "../utils/inventory-receiving";
|
|
176
176
|
|
|
177
177
|
type TMode = "receive" | "issue" | "request" | "return";
|
|
@@ -170,7 +170,7 @@
|
|
|
170
170
|
type="number"
|
|
171
171
|
min="1"
|
|
172
172
|
density="comfortable"
|
|
173
|
-
:suffix="give.holding?.unit"
|
|
173
|
+
:suffix="unitWord(give.qty, give.holding?.unit)"
|
|
174
174
|
:rules="[giveQtyRule]"
|
|
175
175
|
hint="Counted in the unit the store issues it in."
|
|
176
176
|
persistent-hint
|
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
type="number"
|
|
202
202
|
min="1"
|
|
203
203
|
density="comfortable"
|
|
204
|
-
:suffix="ret.holding.unit"
|
|
204
|
+
:suffix="unitWord(ret.qty, ret.holding.unit)"
|
|
205
205
|
style="max-width: 200px"
|
|
206
206
|
:rules="[retQtyRule]"
|
|
207
207
|
/>
|
|
@@ -237,7 +237,7 @@ import useUtils from "../composables/useUtils";
|
|
|
237
237
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
238
238
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
239
239
|
import { colleagueDraftText, draftCardText } from "../utils/inventory-usage";
|
|
240
|
-
import { ackText, holdText, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
|
|
240
|
+
import { ackText, holdText, inventoryStepsLive, qtyUnit, unitWord } from "../utils/inventory-ack";
|
|
241
241
|
|
|
242
242
|
const props = defineProps({
|
|
243
243
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
type="number"
|
|
23
23
|
min="1"
|
|
24
24
|
:max="maxQty"
|
|
25
|
-
:suffix="holding.unit"
|
|
25
|
+
:suffix="unitWord(qty, holding.unit)"
|
|
26
26
|
density="comfortable"
|
|
27
27
|
style="max-width: 200px"
|
|
28
28
|
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <= maxQty) || `1 to ${maxQty}`]"
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
<script setup lang="ts">
|
|
135
135
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
136
136
|
import useUtils from "../composables/useUtils";
|
|
137
|
-
import { inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
|
|
137
|
+
import { inventoryStepsLive, qtyUnit, unitWord } from "../utils/inventory-ack";
|
|
138
138
|
|
|
139
139
|
const show = defineModel<boolean>({ default: false });
|
|
140
140
|
const props = defineProps({
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
min="1"
|
|
24
24
|
:max="max"
|
|
25
25
|
density="comfortable"
|
|
26
|
-
:suffix="unit"
|
|
26
|
+
:suffix="unitWord(qty, unit)"
|
|
27
27
|
style="max-width: 200px"
|
|
28
28
|
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1) || 'Enter a whole number', (v: any) => Number(v) <= max || `You can write off up to ${max}`]"
|
|
29
29
|
/>
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
<script setup lang="ts">
|
|
50
50
|
import useInventory from "../composables/useInventory";
|
|
51
|
-
import { qtyUnit, WRITE_OFF_REASONS, writeOffDefaultReason, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
|
|
51
|
+
import { qtyUnit, unitWord, WRITE_OFF_REASONS, writeOffDefaultReason, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
|
|
52
52
|
|
|
53
53
|
/** Write-off (owner 2026-10-03): from the person's own stock (`holding`) or, for a store keeper, from the shelf (`store`). */
|
|
54
54
|
const show = defineModel<boolean>({ default: false });
|
|
@@ -218,18 +218,31 @@ function body() {
|
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/** Only the newest preview may land: typing a date fires several, and they can answer out of order. */
|
|
222
|
+
let previewSeq = 0;
|
|
221
223
|
async function refresh() {
|
|
222
224
|
if (!from.value) return;
|
|
225
|
+
const mine = ++previewSeq;
|
|
226
|
+
// "On a date" without a date would preview as "Straight away" - ask for the date instead.
|
|
227
|
+
if (when.value === "date" && !endDate.value) {
|
|
228
|
+
preview.value = null;
|
|
229
|
+
loading.value = false;
|
|
230
|
+
error.value = "Pick the date for this change.";
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
223
233
|
loading.value = true;
|
|
224
234
|
error.value = "";
|
|
225
235
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
236
|
+
const res = await previewProviderChange(body());
|
|
237
|
+
if (mine !== previewSeq) return;
|
|
238
|
+
preview.value = res;
|
|
239
|
+
replacements.value = res?.replacements ?? [];
|
|
228
240
|
} catch (e: any) {
|
|
241
|
+
if (mine !== previewSeq) return;
|
|
229
242
|
preview.value = null;
|
|
230
243
|
error.value = message(e);
|
|
231
244
|
} finally {
|
|
232
|
-
loading.value = false;
|
|
245
|
+
if (mine === previewSeq) loading.value = false;
|
|
233
246
|
}
|
|
234
247
|
}
|
|
235
248
|
|
|
@@ -83,8 +83,8 @@
|
|
|
83
83
|
</p>
|
|
84
84
|
<div class="text-body-2 mb-1">QR code shape</div>
|
|
85
85
|
<p class="text-caption text-medium-emphasis mb-1">
|
|
86
|
-
In a shaped code the dots fill the whole shape
|
|
87
|
-
|
|
86
|
+
In a shaped code the dots fill the whole shape right up to the code, so no square shows. The plate number goes
|
|
87
|
+
under the shape, or leave the text off.
|
|
88
88
|
</p>
|
|
89
89
|
<div class="shape-picker" role="radiogroup" aria-label="QR code shape" data-test="vehicle-qr-shapes">
|
|
90
90
|
<button v-for="s in shapeThumbs" :key="s.value" type="button" role="radio" class="shape-option"
|
|
@@ -113,7 +113,11 @@
|
|
|
113
113
|
<v-select v-model="design.theme" :items="STICKER_THEMES" item-value="id" label="Colours" density="compact"
|
|
114
114
|
hide-details />
|
|
115
115
|
</v-col>
|
|
116
|
-
<v-col cols="12" sm="6" md="4">
|
|
116
|
+
<v-col v-if="design.shape !== 'square'" cols="12" sm="6" md="4">
|
|
117
|
+
<v-select v-model="design.text" :items="TEXT_OPTIONS" label="Text" density="compact" hide-details
|
|
118
|
+
data-test="vehicle-qr-text" />
|
|
119
|
+
</v-col>
|
|
120
|
+
<v-col v-if="design.shape === 'square' || design.text !== 'none'" cols="12" sm="6" md="4">
|
|
117
121
|
<v-select v-model="design.label" :items="LABEL_OPTIONS" label="Words under the plate" density="compact"
|
|
118
122
|
hide-details />
|
|
119
123
|
</v-col>
|
|
@@ -187,6 +191,7 @@ import {
|
|
|
187
191
|
QR_SHAPE_OPTIONS,
|
|
188
192
|
QR_ECC,
|
|
189
193
|
STICKER_THEMES,
|
|
194
|
+
TEXT_OPTIONS,
|
|
190
195
|
frameOptionsFor,
|
|
191
196
|
normalizeDesign,
|
|
192
197
|
sizeOptionsFor,
|
|
@@ -269,7 +274,7 @@ watch(
|
|
|
269
274
|
// A shape change can leave a frame or size that shape does not offer (the badge
|
|
270
275
|
// frame, or a size whose QR would print under 25 mm): fall back to one it does.
|
|
271
276
|
watch(
|
|
272
|
-
() => [design.shape, design.frame, design.size],
|
|
277
|
+
() => [design.shape, design.frame, design.size, design.text],
|
|
273
278
|
() => {
|
|
274
279
|
const fixed = normalizeDesign(design);
|
|
275
280
|
if (!sizeOptionsFor(fixed, STICKER_QR_MODULES).some((o) => o.value === fixed.size)) fixed.size = "standard";
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.99.1",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
package/utils/inventory-ack.ts
CHANGED
|
@@ -20,11 +20,15 @@ const UNIT_FORMS: Record<string, [string, string]> = {
|
|
|
20
20
|
metre: ["metre", "metres"], metres: ["metre", "metres"],
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
/** The unit word for a quantity: "pc" for 1, "pcs" otherwise; kg and unknown units unchanged. */
|
|
24
|
+
export function unitWord(qty: number | string | null | undefined, unit?: string | null) {
|
|
25
|
+
const forms = UNIT_FORMS[String(unit ?? "").trim().toLowerCase()];
|
|
26
|
+
return forms ? forms[Math.abs(Number(qty)) === 1 ? 0 : 1] : (unit ?? "");
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
/** "1 pc", "2 pcs", "1 box", "3 boxes"; kg and unknown units unchanged. */
|
|
24
30
|
export function qtyUnit(qty: number | string | null | undefined, unit?: string | null) {
|
|
25
|
-
|
|
26
|
-
const word = forms ? forms[Math.abs(Number(qty)) === 1 ? 0 : 1] : (unit ?? "");
|
|
27
|
-
return `${qty ?? ""} ${word}`.trim();
|
|
31
|
+
return `${qty ?? ""} ${unitWord(qty, unit)}`.trim();
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
/** "you hold 2 pcs" or, while some still wait for Received, "you hold 2 pcs (2 waiting for you to tap Received)". */
|
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - Square (classic): the 32 mm QR (ECC Q, 4-module quiet zone) on a card.
|
|
9
9
|
* - Shaped QR: the code itself takes the shape (heart, circle, car ...). The
|
|
10
|
-
* real QR (same ECC Q) sits in the middle
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* real QR (same ECC Q) sits in the middle; the rest of the shape is filled
|
|
11
|
+
* with extra modules on the same grid, in the same dot style and density,
|
|
12
|
+
* right up to the code's edge - so no square shows. Only the three corner
|
|
13
|
+
* eyes keep a one-module paper ring (a scanner finds the code by them). The
|
|
14
|
+
* extra modules are seeded by the token (same sticker every print) and can
|
|
15
|
+
* never form the patterns a scanner hunts for (no 2 x 2 dark block, so no QR
|
|
16
|
+
* corner; no dark-light-dark-light-dark timing run). The plate number sits
|
|
17
|
+
* under the shape, not in it.
|
|
16
18
|
*
|
|
17
19
|
* The shapes are kept as data (`stickerFacePaints`) so the tests can draw them
|
|
18
20
|
* to pixels and decode them - every shape x size x style must still scan.
|
|
@@ -42,6 +44,8 @@ export type QrShape = "square" | SilhouetteShape;
|
|
|
42
44
|
export type FrameShape = "none" | "rounded" | "square" | "soft" | "arch" | "ticket";
|
|
43
45
|
export type SizeChoice = "small" | "standard" | "large";
|
|
44
46
|
export type LabelChoice = "app" | "verify" | "scanme" | "site" | "none";
|
|
47
|
+
/** Shaped codes: the plate number on a tab under the shape, or no text at all. */
|
|
48
|
+
export type TextChoice = "below" | "none";
|
|
45
49
|
|
|
46
50
|
export type StickerTheme = { id: string; title: string; ink: string; eye: string; paper: string };
|
|
47
51
|
|
|
@@ -54,6 +58,8 @@ export type StickerDesign = {
|
|
|
54
58
|
frame: FrameShape;
|
|
55
59
|
size: SizeChoice;
|
|
56
60
|
label: LabelChoice;
|
|
61
|
+
/** Shaped codes only: plate number under the shape, or none (only the shape). */
|
|
62
|
+
text: TextChoice;
|
|
57
63
|
/** A thin grey line around each sticker to cut along. */
|
|
58
64
|
cutLines?: boolean;
|
|
59
65
|
};
|
|
@@ -119,6 +125,11 @@ export function isSilhouette(shape: unknown): shape is SilhouetteShape {
|
|
|
119
125
|
return SILHOUETTE_OPTIONS.some((o) => o.value === shape);
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
export const TEXT_OPTIONS: { value: TextChoice; title: string }[] = [
|
|
129
|
+
{ value: "below", title: "Plate number under the shape" },
|
|
130
|
+
{ value: "none", title: "No text (only the shape)" },
|
|
131
|
+
];
|
|
132
|
+
|
|
122
133
|
export const LABEL_OPTIONS: { value: LabelChoice; title: string }[] = [
|
|
123
134
|
{ value: "app", title: "Scan with iSecure365 / iMA365" },
|
|
124
135
|
{ value: "verify", title: "Scan to verify vehicle" },
|
|
@@ -146,6 +157,7 @@ export const DEFAULT_STICKER_DESIGN: StickerDesign = {
|
|
|
146
157
|
frame: "rounded",
|
|
147
158
|
size: "standard",
|
|
148
159
|
label: "app",
|
|
160
|
+
text: "below",
|
|
149
161
|
cutLines: true,
|
|
150
162
|
};
|
|
151
163
|
|
|
@@ -167,6 +179,7 @@ export function normalizeDesign(raw: unknown): StickerDesign {
|
|
|
167
179
|
frame,
|
|
168
180
|
size: pick(r.size, SIZE_OPTIONS, d.size),
|
|
169
181
|
label: pick(r.label, LABEL_OPTIONS, d.label),
|
|
182
|
+
text: pick(r.text, TEXT_OPTIONS, d.text),
|
|
170
183
|
cutLines: typeof r.cutLines === "boolean" ? r.cutLines : true,
|
|
171
184
|
};
|
|
172
185
|
}
|
|
@@ -200,10 +213,19 @@ export const QUIET_MODULES = 4;
|
|
|
200
213
|
*/
|
|
201
214
|
export const QR_ECC = "Q";
|
|
202
215
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
216
|
+
* Room kept round a shaped code inside the shape, in modules: the paper ring
|
|
217
|
+
* round each corner eye has to stay on the sticker. The pattern fills this
|
|
218
|
+
* room everywhere else.
|
|
205
219
|
*/
|
|
206
|
-
export const QR_RING =
|
|
220
|
+
export const QR_RING = 1;
|
|
221
|
+
/**
|
|
222
|
+
* Paper round each corner eye (finder), in modules: a scanner finds the code by
|
|
223
|
+
* the eye's dark-light-dark rings. 1 missed 16 of 9261 reads on ZXing, 2 read
|
|
224
|
+
* all once the edge margin was 1.5 mm. The pattern is full-strength ink: a
|
|
225
|
+
* lighter tint for the pattern (tried 55% paper, near the code only or
|
|
226
|
+
* everywhere) read worse on ZXing and made the square show again.
|
|
227
|
+
*/
|
|
228
|
+
export const FINDER_CLEAR = 2;
|
|
207
229
|
const STROKE = 0.35;
|
|
208
230
|
|
|
209
231
|
/** A rectangle with per-corner radii [tl, tr, br, bl], or concave cut corners. */
|
|
@@ -220,17 +242,19 @@ export type Paint = { shape: Shape; hole?: Shape; fill: string };
|
|
|
220
242
|
export const SHAPE_W = 62;
|
|
221
243
|
export const SHAPE_H = 58;
|
|
222
244
|
/** The code + ring keeps at least this much paper to the shape's edge, so a slightly-off cut never reaches the code. */
|
|
223
|
-
export const SHAPE_EDGE_MARGIN = 1;
|
|
245
|
+
export const SHAPE_EDGE_MARGIN = 1.5;
|
|
224
246
|
/** Card round a shaped code (frame other than "none"): this much card on every side. */
|
|
225
247
|
export const FRAME_PAD = 2.5;
|
|
226
248
|
/**
|
|
227
|
-
* The plate
|
|
228
|
-
*
|
|
229
|
-
*
|
|
249
|
+
* The plate tab: a paper tab under the shape (text "below"), plate number (and
|
|
250
|
+
* the optional words) in ink. The shape sits above it and just touches it, so
|
|
251
|
+
* shape and tab cut out as one piece and the pattern has no hole in it.
|
|
230
252
|
*/
|
|
231
253
|
export const PLATE_TAG = { x: 14, y: 50, w: 34, h: 8 };
|
|
254
|
+
/** How far the tab reaches up into the shape's bottom edge, mm. */
|
|
255
|
+
const TAB_OVERLAP = 0.5;
|
|
232
256
|
/** Chance a pattern cell is tried; after the no-block / no-timing rules about half the cells are dark, like the code. */
|
|
233
|
-
const PATTERN_DENSITY = 0.
|
|
257
|
+
const PATTERN_DENSITY = 0.8;
|
|
234
258
|
|
|
235
259
|
type Pt = [number, number];
|
|
236
260
|
const MID = SHAPE_W / 2;
|
|
@@ -383,29 +407,29 @@ export function rectInside(pts: Pt[], x: number, y: number, w: number, h: number
|
|
|
383
407
|
}
|
|
384
408
|
|
|
385
409
|
/** Where the code goes in a shape: `qr` is the code + its paper ring (a square, side `s`); `band` is the plate tag. */
|
|
386
|
-
export type SilhouetteLayout = { pts: Pt[]; qr: { x: number; y: number; s: number }; band
|
|
387
|
-
const layoutCache = new Map<
|
|
410
|
+
export type SilhouetteLayout = { pts: Pt[]; qr: { x: number; y: number; s: number }; band?: { x: number; y: number; w: number; h: number } };
|
|
411
|
+
const layoutCache = new Map<string, SilhouetteLayout>();
|
|
388
412
|
|
|
389
|
-
/** Tops where a code square of side s fits inside the shape
|
|
413
|
+
/** Tops where a code square of side s fits inside the shape. */
|
|
390
414
|
function fits(pts: Pt[], s: number): number[] {
|
|
391
415
|
const e = SHAPE_EDGE_MARGIN;
|
|
392
416
|
const tops: number[] = [];
|
|
393
|
-
for (let top = e; top + s + e <=
|
|
417
|
+
for (let top = e; top + s + e <= SHAPE_H; top += 0.25) {
|
|
394
418
|
if (rectInside(pts, MID - s / 2 - e, top - e, s + 2 * e, s + 2 * e)) tops.push(top);
|
|
395
419
|
}
|
|
396
420
|
return tops;
|
|
397
421
|
}
|
|
398
422
|
|
|
399
423
|
/**
|
|
400
|
-
* The biggest code that fits the shape
|
|
401
|
-
*
|
|
402
|
-
*
|
|
424
|
+
* The biggest code that fits the shape, keeping SHAPE_EDGE_MARGIN of paper to
|
|
425
|
+
* the edge. Bigger code = bigger modules = easier to scan; the pattern fills
|
|
426
|
+
* what is left. With the plate below, the shape is shrunk to sit above the tab.
|
|
403
427
|
*/
|
|
404
|
-
export function silhouetteLayout(shape: SilhouetteShape): SilhouetteLayout {
|
|
405
|
-
const
|
|
428
|
+
export function silhouetteLayout(shape: SilhouetteShape, text: TextChoice = "below"): SilhouetteLayout {
|
|
429
|
+
const key = `${shape}:${text}`;
|
|
430
|
+
const cached = layoutCache.get(key);
|
|
406
431
|
if (cached) return cached;
|
|
407
|
-
|
|
408
|
-
const k = (PLATE_TAG.y + 1.5) / SHAPE_H;
|
|
432
|
+
const k = text === "none" ? 1 : (PLATE_TAG.y + TAB_OVERLAP) / SHAPE_H;
|
|
409
433
|
const pts = SILHOUETTE_BUILDERS[shape]().map(([x, y]) => [MID + (x - MID) * k, y * k] as Pt);
|
|
410
434
|
let lo = 10;
|
|
411
435
|
let hi = PLATE_TAG.y;
|
|
@@ -414,12 +438,14 @@ export function silhouetteLayout(shape: SilhouetteShape): SilhouetteLayout {
|
|
|
414
438
|
if (fits(pts, s).length) lo = s;
|
|
415
439
|
else hi = s;
|
|
416
440
|
}
|
|
417
|
-
|
|
441
|
+
// With no text the shape is bigger, but the code keeps its plate-below size and the
|
|
442
|
+
// pattern fills the extra room: a bigger code read worse on ZXing close up (tested).
|
|
443
|
+
const s = text === "none" ? silhouetteLayout(shape, "below").qr.s : Math.floor(lo * 20) / 20;
|
|
418
444
|
const tops = fits(pts, s);
|
|
419
445
|
if (!tops.length) throw new Error(`QR does not fit the ${shape} shape`);
|
|
420
446
|
const top = tops[Math.floor(tops.length / 2)];
|
|
421
|
-
const layout = { pts, qr: { x: MID - s / 2, y: top, s }, band: { ...PLATE_TAG } };
|
|
422
|
-
layoutCache.set(
|
|
447
|
+
const layout = { pts, qr: { x: MID - s / 2, y: top, s }, band: text === "none" ? undefined : { ...PLATE_TAG } };
|
|
448
|
+
layoutCache.set(key, layout);
|
|
423
449
|
return layout;
|
|
424
450
|
}
|
|
425
451
|
|
|
@@ -438,7 +464,7 @@ export type StickerGeometry = {
|
|
|
438
464
|
/** The shape (or card) on paper, mm. */
|
|
439
465
|
shapeW: number;
|
|
440
466
|
shapeH: number;
|
|
441
|
-
/** Shaped codes: outline and plate
|
|
467
|
+
/** Shaped codes: outline and plate tab (none when the text is off), already placed on the face. */
|
|
442
468
|
pts?: Pt[];
|
|
443
469
|
band?: { x: number; y: number; w: number; h: number };
|
|
444
470
|
/** Classic: top of the 32 mm QR on the card. */
|
|
@@ -456,7 +482,7 @@ export function stickerGeometry(design: StickerDesign, n: number): StickerGeomet
|
|
|
456
482
|
dataMm: n * m * scale, shapeW: CARD_W * scale, shapeH: CARD_H * scale, qrTop: top,
|
|
457
483
|
};
|
|
458
484
|
}
|
|
459
|
-
const L = silhouetteLayout(design.shape);
|
|
485
|
+
const L = silhouetteLayout(design.shape, design.text);
|
|
460
486
|
const pad = design.frame === "none" ? 0 : FRAME_PAD;
|
|
461
487
|
const m = L.qr.s / (n + QR_RING * 2);
|
|
462
488
|
const pts = L.pts.map(([x, y]) => [x + pad, y + pad] as Pt);
|
|
@@ -468,7 +494,7 @@ export function stickerGeometry(design: StickerDesign, n: number): StickerGeomet
|
|
|
468
494
|
dataMm: n * m * scale,
|
|
469
495
|
shapeW: (Math.max(...xs) - Math.min(...xs)) * scale,
|
|
470
496
|
shapeH: (Math.max(...ys) - Math.min(...ys)) * scale,
|
|
471
|
-
pts, band: { ...L.band, x: L.band.x + pad, y: L.band.y + pad },
|
|
497
|
+
pts, band: L.band && { ...L.band, x: L.band.x + pad, y: L.band.y + pad },
|
|
472
498
|
};
|
|
473
499
|
}
|
|
474
500
|
|
|
@@ -493,31 +519,49 @@ function seedOf(text: string): number {
|
|
|
493
519
|
|
|
494
520
|
/**
|
|
495
521
|
* The pattern round a shaped code: cells on the code's grid, whole inside the
|
|
496
|
-
* outline,
|
|
497
|
-
*
|
|
498
|
-
*
|
|
522
|
+
* outline, right up to the code but off the paper ring round each corner eye
|
|
523
|
+
* and off the plate tab. A cell stays light if, together with the cells
|
|
524
|
+
* already dark round it - the code's own modules included, so nothing forms
|
|
525
|
+
* across the code's edge - it would make a 2 x 2 dark block (no solid centre =
|
|
526
|
+
* nothing a scanner can take for a QR corner or alignment square), a run of 3
|
|
527
|
+
* dark in a row or column (no corner-eye-like stripe) or a
|
|
499
528
|
* dark-light-dark-light-dark run (a timing line).
|
|
500
529
|
*/
|
|
501
|
-
export function patternCells(g: StickerGeometry,
|
|
530
|
+
export function patternCells(g: StickerGeometry, matrix: QrMatrix, seed = ""): Set<string> {
|
|
502
531
|
const { m, ox, oy, pts, band } = g;
|
|
532
|
+
const n = matrix.size;
|
|
503
533
|
const cells = new Set<string>();
|
|
504
|
-
if (!pts
|
|
534
|
+
if (!pts) return cells;
|
|
505
535
|
const s = seedOf(seed);
|
|
506
536
|
const near = 0.4; // mm of paper kept inside the edge
|
|
507
537
|
const clear = 0.6; // mm of paper kept round the plate tag
|
|
508
|
-
const has = (r: number, c: number) =>
|
|
538
|
+
const has = (r: number, c: number) =>
|
|
539
|
+
r >= 0 && r < n && c >= 0 && c < n ? !!matrix.get(r, c) : cells.has(`${r},${c}`);
|
|
540
|
+
/** Would (r, c) dark complete a scanner-like pattern along direction (dr, dc)? */
|
|
541
|
+
const line = (r: number, c: number, dr: number, dc: number) => {
|
|
542
|
+
const at = (k: number) => k === 0 || has(r + k * dr, c + k * dc);
|
|
543
|
+
for (let k = -2; k <= 0; k++) if (at(k) && at(k + 1) && at(k + 2)) return true; // run of 3
|
|
544
|
+
for (const st of [0, -2, -4]) {
|
|
545
|
+
if (at(st) && at(st + 2) && at(st + 4) && !at(st + 1) && !at(st + 3)) return true; // timing run
|
|
546
|
+
}
|
|
547
|
+
return false;
|
|
548
|
+
};
|
|
549
|
+
/** Would (r, c) dark complete a 2 x 2 block? */
|
|
550
|
+
const block = (r: number, c: number) =>
|
|
551
|
+
[[-1, -1], [-1, 0], [0, -1], [0, 0]].some(([a, b]) =>
|
|
552
|
+
[[0, 0], [0, 1], [1, 0], [1, 1]].every(([i, j]) => (a + i === 0 && b + j === 0) || has(r + a + i, c + b + j)),
|
|
553
|
+
);
|
|
509
554
|
for (let row = Math.floor(-oy / m) - 1; oy + row * m < g.h; row++) {
|
|
510
555
|
for (let col = Math.floor(-ox / m) - 1; ox + col * m < g.w; col++) {
|
|
511
|
-
if (row >=
|
|
556
|
+
if (row >= 0 && row < n && col >= 0 && col < n) continue;
|
|
557
|
+
if (nearFinder(n, row, col)) continue;
|
|
512
558
|
if (hashCell(s, row, col) >= PATTERN_DENSITY) continue;
|
|
513
559
|
const x = ox + col * m;
|
|
514
560
|
const y = oy + row * m;
|
|
515
|
-
if (x + m > band.x - clear && x < band.x + band.w + clear && y + m > band.y - clear && y < band.y + band.h + clear) continue;
|
|
561
|
+
if (band && x + m > band.x - clear && x < band.x + band.w + clear && y + m > band.y - clear && y < band.y + band.h + clear) continue;
|
|
516
562
|
const corners: Pt[] = [[x - near, y - near], [x + m + near, y - near], [x - near, y + m + near], [x + m + near, y + m + near]];
|
|
517
563
|
if (!corners.every(([px, py]) => pointInPoly(pts, px, py))) continue;
|
|
518
|
-
if (
|
|
519
|
-
if (has(row, col - 2) && has(row, col - 4) && !has(row, col - 1) && !has(row, col - 3)) continue;
|
|
520
|
-
if (has(row - 2, col) && has(row - 4, col) && !has(row - 1, col) && !has(row - 3, col)) continue;
|
|
564
|
+
if (block(row, col) || line(row, col, 0, 1) || line(row, col, 1, 0)) continue;
|
|
521
565
|
cells.add(`${row},${col}`);
|
|
522
566
|
}
|
|
523
567
|
}
|
|
@@ -548,6 +592,12 @@ function isFinder(n: number, row: number, col: number): boolean {
|
|
|
548
592
|
return (row < 7 && col < 7) || (row < 7 && col >= n - 7) || (row >= n - 7 && col < 7);
|
|
549
593
|
}
|
|
550
594
|
|
|
595
|
+
/** Within FINDER_CLEAR modules of a corner eye (the eye's own paper ring, outside the code). */
|
|
596
|
+
function nearFinder(n: number, row: number, col: number): boolean {
|
|
597
|
+
const k = FINDER_CLEAR;
|
|
598
|
+
return [[0, 0], [0, n - 7], [n - 7, 0]].some(([fr, fc]) => row >= fr - k && row < fr + 7 + k && col >= fc - k && col < fc + 7 + k);
|
|
599
|
+
}
|
|
600
|
+
|
|
551
601
|
/** Every shape on the sticker face, painted in order. `seed` (the payload) picks the pattern round a shaped code. */
|
|
552
602
|
export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign, seed = ""): Paint[] {
|
|
553
603
|
const theme = stickerTheme(design.theme);
|
|
@@ -564,7 +614,7 @@ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign, seed
|
|
|
564
614
|
}
|
|
565
615
|
}
|
|
566
616
|
const box = (x: number, y: number, w: number, h: number, rad = 0): Box => ({ t: "box", x, y, w, h, r: [rad, rad, rad, rad] });
|
|
567
|
-
if (g.band) paints.push({ shape: box(g.band.x, g.band.y, g.band.w, g.band.h, 2), fill: theme.
|
|
617
|
+
if (g.band) paints.push({ shape: box(g.band.x, g.band.y, g.band.w, g.band.h, 2), fill: theme.paper });
|
|
568
618
|
|
|
569
619
|
// Round dots break the small alignment square (bottom right) into a ring of
|
|
570
620
|
// separate dots that some readers miss, so with dots it is drawn solid.
|
|
@@ -573,7 +623,7 @@ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign, seed
|
|
|
573
623
|
const align = design.dots === "dots" && version >= 2 && version <= 6 ? n - 7 : -1;
|
|
574
624
|
const inAlign = (row: number, col: number) => align > 0 && Math.abs(row - align) <= 2 && Math.abs(col - align) <= 2;
|
|
575
625
|
|
|
576
|
-
const pattern = patternCells(g,
|
|
626
|
+
const pattern = patternCells(g, matrix, seed);
|
|
577
627
|
const inCode = (row: number, col: number) => row >= 0 && col >= 0 && row < n && col < n;
|
|
578
628
|
const dark = (row: number, col: number) =>
|
|
579
629
|
inCode(row, col) ? !!matrix.get(row, col) && !isFinder(n, row, col) && !inAlign(row, col) : pattern.has(`${row},${col}`);
|
|
@@ -705,8 +755,8 @@ export function paintsToSvg(
|
|
|
705
755
|
export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign, seed = ""): string {
|
|
706
756
|
const g = stickerGeometry(design, matrix.size);
|
|
707
757
|
const paints = stickerFacePaints(matrix, design, seed);
|
|
708
|
-
// A shaped code with no card is cut round the shape and its plate
|
|
709
|
-
const cutShapes = g.pts && design.frame === "none" ? [paints[0].shape, paints[1].shape] : undefined;
|
|
758
|
+
// A shaped code with no card is cut round the shape and its plate tab together.
|
|
759
|
+
const cutShapes = g.pts && design.frame === "none" ? (g.band ? [paints[0].shape, paints[1].shape] : [paints[0].shape]) : undefined;
|
|
710
760
|
return paintsToSvg(paints, { w: g.w, h: g.h, scale: g.scale, cutLine: design.cutLines !== false, cutShapes });
|
|
711
761
|
}
|
|
712
762
|
|
|
@@ -89,13 +89,14 @@ export function stickerSizeInfo(design: StickerDesign, n = STICKER_QR_MODULES):
|
|
|
89
89
|
const l = sheetLayout(design);
|
|
90
90
|
const cut = design.cutLines !== false ? 2 * CUT_LINE_GAP : 0;
|
|
91
91
|
const bare = isSilhouette(design.shape) && design.frame === "none";
|
|
92
|
+
// a bare shape is cut round the shape and its plate tab (if any); anything else round the card
|
|
93
|
+
const ys = bare ? [...g.pts!.map((p) => p[1]), ...(g.band ? [g.band.y + g.band.h] : [])] : [0, g.h];
|
|
92
94
|
return {
|
|
93
95
|
qrMm: mm(g.dataMm),
|
|
94
96
|
shapeW: mm(g.shapeW),
|
|
95
97
|
shapeH: mm(g.shapeH),
|
|
96
|
-
// a bare shape is cut round the shape and its plate tag; anything else round the card
|
|
97
98
|
stickerW: mm((bare ? g.shapeW : g.w * g.scale) + cut),
|
|
98
|
-
stickerH: mm(
|
|
99
|
+
stickerH: mm((Math.max(...ys) - Math.min(...ys)) * g.scale + cut),
|
|
99
100
|
perSheet: perSheet(l),
|
|
100
101
|
gap: l.gap,
|
|
101
102
|
};
|
|
@@ -348,20 +349,23 @@ export function buildStickerDocument(
|
|
|
348
349
|
): string {
|
|
349
350
|
const design = opts.design ?? DEFAULT_STICKER_DESIGN;
|
|
350
351
|
const n = opts.modules ?? STICKER_QR_MODULES;
|
|
351
|
-
const { ink
|
|
352
|
+
const { ink } = stickerTheme(design.theme);
|
|
352
353
|
const label = stickerLabel(design, opts.siteName);
|
|
353
354
|
const layout = sheetLayout(design);
|
|
354
355
|
const g = stickerGeometry(design, n);
|
|
355
356
|
const k = g.scale;
|
|
356
|
-
// Shaped codes: plate (and words) in paper
|
|
357
|
+
// Shaped codes: plate (and words) in ink on the paper tab under the shape - or no text at all.
|
|
358
|
+
const shaped = !!g.pts;
|
|
357
359
|
const tag = g.band ? { x: mm(g.band.x * k), y: mm(g.band.y * k), w: mm(g.band.w * k), h: mm(g.band.h * k) } : null;
|
|
358
|
-
const bare =
|
|
360
|
+
const bare = shaped && design.frame === "none";
|
|
359
361
|
const wordsTop = mm(((g.qrTop ?? 0) + QR_MM + 0.8) * k);
|
|
360
362
|
const cells = items.map((item, i) => {
|
|
361
363
|
const { place, owner } = installerLine(item);
|
|
362
364
|
const plate = escapeHtml(item.plateNumber);
|
|
363
|
-
const words = tag
|
|
364
|
-
?
|
|
365
|
+
const words = shaped && !tag
|
|
366
|
+
? ""
|
|
367
|
+
: tag
|
|
368
|
+
? `<div class="words band" style="left:${tag.x}mm;right:auto;top:${tag.y}mm;width:${tag.w}mm;height:${tag.h}mm">` +
|
|
365
369
|
`<div class="plate" style="font-size:${plateSizeMm(text(item.plateNumber), tag.w, 4.6 * k)}mm">${plate}</div>` +
|
|
366
370
|
(label ? `<div class="hint">${escapeHtml(label)}</div>` : "") +
|
|
367
371
|
`</div>`
|